diff --git a/bun.lock b/bun.lock index a9a6f51..c9c8e3e 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,7 @@ "dayjs": "^1.11.13", "react": "^19.0.0", "react-dom": "^19.0.0", + "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.5", "zustand": "^5.0.4", }, @@ -1025,6 +1026,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tailwind-merge": ["tailwind-merge@3.2.0", "", {}, "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA=="], + "tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="], "tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="], diff --git a/packages/server/src/auth/auth.ts b/packages/server/src/auth/auth.ts index 35e5419..67a0415 100644 --- a/packages/server/src/auth/auth.ts +++ b/packages/server/src/auth/auth.ts @@ -1,4 +1,4 @@ -import { type User, DEMO_USER } from "@markone/core/user" +import { DEMO_USER, type User } from "@markone/core/user" import { type } from "arktype" import dayjs from "dayjs" import { ulid } from "ulid" @@ -6,6 +6,7 @@ import { db } from "~/database.ts" import { HttpError } from "~/error.ts" import { httpHandler } from "~/http-handler.ts" import { createUser, findUserById, findUserByUsername } from "~/user/user.ts" +import { hashPassword, verifyPassword } from "./password.ts" import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts" const SignUpRequest = type({ @@ -18,20 +19,13 @@ const LoginRequest = type({ password: "string", }) -const createAuthTokenQuery = db.query( - "INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)", -) - -const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id") - -const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId") - function authenticated( handler: (request: Bun.BunRequest, user: User) => Promise, ) { return httpHandler((request) => { const session = verifySession(request.cookies) if (!session) { + console.log("session not found!") throw new HttpError(401) } @@ -42,6 +36,8 @@ function authenticated( const authTokenCookie = request.cookies.get("auth-token") if (authTokenCookie) { + const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id") + // biome-ignore lint/style/noNonNullAssertion: the cookie has already been verified by verifySession previously, therefore the cookie must be in the correct format : const tokenId = authTokenCookie.split(":")[0]! deleteAuthTokenQuery.run({ id: tokenId }) @@ -69,6 +65,10 @@ function rememberLoginForUser(user: User, cookies: Bun.CookieMap) { const expiryDate = dayjs().add(1, "month") + const createAuthTokenQuery = db.query( + "INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)", + ) + createAuthTokenQuery.run({ id: tokenId, token: hashedToken, @@ -78,6 +78,7 @@ function rememberLoginForUser(user: User, cookies: Bun.CookieMap) { cookies.set("auth-token", `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, { maxAge: 30 * 24 * 60 * 60 * 1000, + path: "/api", httpOnly: true, }) } @@ -93,7 +94,7 @@ async function signUp(request: Bun.BunRequest<"/api/sign-up">) { } const { username, password } = signUpRequest - const hashedPassword = await Bun.password.hash(password, "argon2id") + const hashedPassword = await hashPassword(password) const user = createUser(username, hashedPassword) await createSessionForUser(user, request.cookies) @@ -116,10 +117,10 @@ async function login(request: Bun.BunRequest<"/api/login">) { password: true, }) if (!foundUser) { - throw new HttpError(400) + throw new HttpError(401) } - const ok = await Bun.password.verify(loginRequest.password, foundUser.password, "argon2id").catch(() => { + const ok = await verifyPassword(loginRequest.password, foundUser.password).catch(() => { throw new HttpError(401) }) if (!ok) { @@ -131,8 +132,8 @@ async function login(request: Bun.BunRequest<"/api/login">) { username: foundUser.username, } - if (user.id === DEMO_USER.id) { - await createSessionForUser(user, request.cookies) + await createSessionForUser(user, request.cookies) + if (user.id !== DEMO_USER.id) { rememberLoginForUser(user, request.cookies) } @@ -140,6 +141,8 @@ async function login(request: Bun.BunRequest<"/api/login">) { } async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise { + const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId") + forgetAllSessions(user) deleteAllAuthTokensQuery.run({ userId: user.id }) return new Response(undefined, { status: 200 }) diff --git a/packages/server/src/auth/password.ts b/packages/server/src/auth/password.ts new file mode 100644 index 0000000..eb7c6d7 --- /dev/null +++ b/packages/server/src/auth/password.ts @@ -0,0 +1,11 @@ +const HASH_ALGORITHM = "argon2id" + +async function hashPassword(input: string): Promise { + return await Bun.password.hash(input, HASH_ALGORITHM) +} + +async function verifyPassword(input: string, hash: string) { + return await Bun.password.verify(input, hash, HASH_ALGORITHM) +} + +export { hashPassword, verifyPassword } diff --git a/packages/server/src/auth/session.ts b/packages/server/src/auth/session.ts index adf20f4..751ee77 100644 --- a/packages/server/src/auth/session.ts +++ b/packages/server/src/auth/session.ts @@ -15,21 +15,8 @@ const SESSION_ID_BYTE_LENGTH = 24 const SESSION_ID_COOKIE_NAME = "session-id" const SESSION_DURATION_MS = 30 * 60 * 1000 -const findSessionQuery = db.query("SELECT user_id, expires_at_unix FROM sessions WHERE session_id = $sessionId") - -const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId") -const forgetAllSessionsQuery = db.query("DELETE FROM sessions WHERE user_id = $userId") -const deleteExpiredSessionsQuery = db.query("DELETE FROM sessions WHERE expires_at_unix_ms < $time") - -const saveSessionQuery = db.query( - "INSERT INTO sessions(session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)", -) - -const extendSessionQuery = db.query( - "UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id", -) - function startBackgroundSessionCleanup() { + const deleteExpiredSessionsQuery = db.query("DELETE FROM sessions WHERE expires_at_unix_ms < $time") setInterval(() => { deleteExpiredSessionsQuery.run({ time: dayjs().valueOf() }) }, 5000) @@ -48,9 +35,11 @@ function signSessionId(sessionId: string): string { async function createSessionForUser(user: User, cookies: Bun.CookieMap) { const sessionId = await newSessionId() const signedSessionId = signSessionId(sessionId) - const expiryDate = dayjs().add(30, "minutes").valueOf() + const saveSessionQuery = db.query( + "INSERT INTO sessions (session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)", + ) saveSessionQuery.run({ sessionId, userId: user.id, @@ -59,13 +48,17 @@ async function createSessionForUser(user: User, cookies: Bun.CookieMap) { cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, { maxAge: user.id === DEMO_USER.id ? undefined : SESSION_DURATION_MS, + path: "/api", httpOnly: true, }) + + console.log("session created for user", user.id) } async function saveSession(session: Session, cookies: Bun.CookieMap) { cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, { maxAge: SESSION_DURATION_MS, + path: "/api", httpOnly: true, }) } @@ -73,6 +66,7 @@ async function saveSession(session: Session, cookies: Bun.CookieMap) { function verifySession(cookie: Bun.CookieMap): Session | null { const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME) if (!signedSessionId) { + console.log("no cookie") return null } @@ -84,18 +78,23 @@ function verifySession(cookie: Bun.CookieMap): Session | null { const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b) if (!isEqual) { + console.log("not equal") return null } + const findSessionQuery = db.query("SELECT user_id, expires_at_unix_ms FROM sessions WHERE session_id = $sessionId") const row = findSessionQuery.get({ sessionId: value }) if (!row) { + console.log("no row") return null } const foundSession = row as { user_id: string; expires_at_unix_ms: number } const now = dayjs().valueOf() if (now > foundSession.expires_at_unix_ms) { + const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId") deleteSessionQuery.run({ sessionId: value }) + console.log("session expired!") return null } @@ -110,6 +109,9 @@ function verifySession(cookie: Bun.CookieMap): Session | null { function extendSession(session: Session): Session { const newExpiryDate = dayjs().add(30, "minutes").valueOf() + const extendSessionQuery = db.query( + "UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id", + ) extendSessionQuery.run({ sessionId: session.id, newExpiryDate, @@ -121,6 +123,7 @@ function extendSession(session: Session): Session { } function forgetAllSessions(user: User) { + const forgetAllSessionsQuery = db.query("DELETE FROM sessions WHERE user_id = $userId") forgetAllSessionsQuery.run({ userId: user.id }) } diff --git a/packages/server/src/bookmark/bookmark.ts b/packages/server/src/bookmark/bookmark.ts new file mode 100644 index 0000000..36cbcb4 --- /dev/null +++ b/packages/server/src/bookmark/bookmark.ts @@ -0,0 +1,24 @@ +import type { User } from "@markone/core/user" +import { db } from "~/database.ts" +import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts" + +function insertDemoBookmarks(user: User) { + const query = db.query(` +INSERT OR IGNORE INTO bookmarks (id, user_id, kind, title, url) +VALUES ($id, $userId, $kind, $title, $url) +`) + const insert = db.transaction((bookmarks) => { + for (const bookmark of bookmarks) { + query.run({ + id: bookmark.id, + userId: user.id, + kind: bookmark.kind, + title: bookmark.title, + url: bookmark.url, + }) + } + }) + insert(DEMO_BOOKMARKS) +} + +export { insertDemoBookmarks } diff --git a/packages/server/src/bookmark/demo-bookmarks.ts b/packages/server/src/bookmark/demo-bookmarks.ts new file mode 100644 index 0000000..5eb7f4c --- /dev/null +++ b/packages/server/src/bookmark/demo-bookmarks.ts @@ -0,0 +1,66 @@ +import type { Bookmark } from "@markone/core/bookmark" + +const DEMO_BOOKMARKS: Bookmark[] = [ + { + kind: "link", + id: "01JTKBKVMWRRDR20PY9X4HGPVY", + title: "ULID Specification", + url: "https://github.com/ulid/spec", + }, + { + kind: "link", + id: "01JTKBKVMW8392A9361PFGZEGM", + title: "Another Example Site", + url: "https://www.example.net", + }, + { + kind: "link", + id: "01JTKBKVMW3YKY4CMANEK7502N", + title: "Documentation Hub", + url: "https://docs.example.com", + }, + { + kind: "link", + id: "01JTKBKVMWHH7QG0CFQ6Q40E2G", + title: "API Reference", + url: "https://api.example.com/docs", + }, + { + kind: "link", + id: "01JTKBKVMWVR4T3KXXB3PHZD60", + title: "Blog Posts", + url: "https://blog.example.com", + }, + { + kind: "link", + id: "01JTKBKVMWAP0GGKWBN7Z3CYAM", + title: "Support Forum", + url: "https://forum.example.com", + }, + { + kind: "link", + id: "01JTKBKVMW1YJB63F2YCMZNGFE", + title: "Tutorials", + url: "https://tutorials.example.com", + }, + { + kind: "link", + id: "01JTKBKVMW93QYS2EK983RQS40", + title: "Resource Library", + url: "https://resources.example.com", + }, + { + kind: "link", + id: "01JTKBKVMW6NG61138T8SCRFYT", + title: "Community Page", + url: "https://community.example.com", + }, + { + kind: "link", + id: "01JTKBKVMWDBKSXYHGFXC0HEZB", + title: "Project Repository", + url: "https://github.com/example/project", + }, +] + +export { DEMO_BOOKMARKS } diff --git a/packages/server/src/bookmark/handlers.ts b/packages/server/src/bookmark/handlers.ts index 9d0217f..0ddc3fb 100644 --- a/packages/server/src/bookmark/handlers.ts +++ b/packages/server/src/bookmark/handlers.ts @@ -1,3 +1,4 @@ +import { DEMO_USER } from "@markone/core/user" import { type } from "arktype" import { db } from "~/database.ts" import { HttpError } from "~/error.ts" @@ -10,16 +11,16 @@ const ListUserBookmarksParams = type({ skip: ["number", "=", 5], }) -const listBookmarksQuery = db.query( - "SELECT id, kind, title, url FROM bookmarks WHERE user_id = $userId LIMIT $limit OFFSET $skip", -) - async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) { const queryParams = ListUserBookmarksParams(request.params) if (queryParams instanceof type.errors) { throw new HttpError(400, queryParams.summary) } + const listBookmarksQuery = db.query( + "SELECT id, kind, title, url FROM bookmarks WHERE user_id = $userId ORDER BY id LIMIT $limit OFFSET $skip", + ) + const results = listBookmarksQuery.all({ userId: user.id, limit: queryParams.limit, @@ -29,4 +30,16 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user return Response.json(results, { status: 200 }) } -export { listUserBookmarks } +async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: User) { + console.log("askldjlskajdkl") + if (user.id !== DEMO_USER.id) { + const deleteBookmarkQuery = db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id") + const tx = db.transaction(() => { + deleteBookmarkQuery.run({ userId: user.id, id: request.params.id }) + }) + tx() + } + return Response.json(undefined, { status: 204 }) +} + +export { listUserBookmarks, deleteUserBookmark } diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 9e19c4b..dd49d3f 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -2,7 +2,7 @@ import { Database } from "bun:sqlite" const SCHEMA_VERSION = 0 -const db = new Database("data.sqlite") +const db = new Database("data.sqlite", { strict: true }) const createMetadataTableQuery = db.query(` CREATE TABLE IF NOT EXISTS metadata( @@ -12,15 +12,12 @@ const createMetadataTableQuery = db.query(` ); `) -const schemaVersionQuery = db.query("SELECT version FROM metadata WHERE key = 'schema_version'") -const setSchemaVersionQuery = db.query("UPDATE metadata SET value = $schemaVersion WHERE key = 'schema_version'") - const migrations = [ ` CREATE TABLE IF NOT EXISTS users( id TEXT PRIMARY KEY, username TEXT NOT NULL, - password TEXT NOT NULL, + password TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS bookmarks( @@ -47,17 +44,22 @@ CREATE TABLE IF NOT EXISTS auth_tokens( `, ] -const executeMigrations = db.transaction((migration) => { - db.run(migration) +const executeMigrations = db.transaction((migrations) => { + for (const migration of migrations) { + db.run(migration) + } }) function migrateDatabase() { createMetadataTableQuery.run() + const schemaVersionQuery = db.query("SELECT value FROM metadata WHERE key = 'schema_version'") + const setSchemaVersionQuery = db.query("UPDATE metadata SET value = $schemaVersion WHERE key = 'schema_version'") + const row = schemaVersionQuery.get() let currentVersion: number if (row) { - currentVersion = (row as { version: number }).version + currentVersion = (row as { value: number }).value console.log(`Migrating database from version ${currentVersion} to version ${SCHEMA_VERSION}...`) } else { currentVersion = -1 diff --git a/packages/server/src/http-handler.ts b/packages/server/src/http-handler.ts index 54a4569..8219685 100644 --- a/packages/server/src/http-handler.ts +++ b/packages/server/src/http-handler.ts @@ -1,22 +1,46 @@ import { HttpError } from "./error.ts" +type HttpMethod = "GET" | "POST" | "DELETE" | "PUT" | "OPTIONS" | "PATCH" + +const ALLOWED_ORIGINS = ["http://localhost:5173"] + function httpHandler( handler: (request: Bun.BunRequest) => Promise, ): (request: Bun.BunRequest) => Promise { return async (request) => { + let response: Response try { - const response = await handler(request) - return response + response = await handler(request) } catch (error) { if (error instanceof HttpError) { if (error.message) { - return Response.json({ message: error.message }, { status: error.status }) + response = Response.json({ message: error.message }, { status: error.status }) + } else { + response = new Response(undefined, { status: error.status }) } - return new Response(undefined, { status: error.status }) + } else { + console.error(error) + response = new Response(undefined, { status: 500 }) } - return new Response(undefined, { status: 500 }) } + for (const origin of ALLOWED_ORIGINS) { + response.headers.set("Access-Control-Allow-Origin", origin) + } + response.headers.set("Access-Control-Allow-Credentials", "true") + return response } } -export { httpHandler } +function preflightHandler({ allowedMethods }: { allowedMethods: HttpMethod[] }) { + return async (request: Bun.BunRequest) => + new Response(undefined, { + status: 200, + headers: { + "Access-Control-Allow-Origin": ALLOWED_ORIGINS, + "Access-Control-Allow-Methods": allowedMethods.join(", "), + "Access-Control-Allow-Credentials": "true", + }, + }) +} + +export { httpHandler, preflightHandler } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index d00dd3d..7cc0274 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,13 +1,15 @@ import { authenticated, login, logout, signUp } from "./auth/auth.ts" import { startBackgroundSessionCleanup } from "./auth/session.ts" -import { listUserBookmarks } from "./bookmark/handlers.ts" +import { insertDemoBookmarks } from "./bookmark/bookmark.ts" +import { listUserBookmarks, deleteUserBookmark } from "./bookmark/handlers.ts" import { migrateDatabase } from "./database.ts" -import { httpHandler } from "./http-handler.ts" +import { httpHandler, preflightHandler } from "./http-handler.ts" import { createDemoUser } from "./user/user.ts" -function main() { +async function main() { migrateDatabase() - createDemoUser() + const user = await createDemoUser() + insertDemoBookmarks(user) startBackgroundSessionCleanup() Bun.serve({ @@ -24,7 +26,15 @@ function main() { "/api/bookmarks": { GET: authenticated(listUserBookmarks), }, + "/api/bookmark/:id": { + DELETE: authenticated(deleteUserBookmark), + OPTIONS: preflightHandler({ + allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"], + }), + }, }, + + port: 8080, }) } diff --git a/packages/server/src/user/user.ts b/packages/server/src/user/user.ts index b82a3b3..205fa51 100644 --- a/packages/server/src/user/user.ts +++ b/packages/server/src/user/user.ts @@ -1,20 +1,17 @@ -import { type User, DEMO_USER } from "@markone/core/user" +import { DEMO_USER, type User } from "@markone/core/user" import { ulid } from "ulid" import { db } from "~/database.ts" +import { DEMO_BOOKMARKS } from "~/bookmark/demo-bookmarks.ts" interface UserWithPassword extends User { password: string } -const findUserByIdQuery = db.query("SELECT id, username FROM users WHERE id = $userId") - -const findUserByUsernameQuery = db.query("SELECT id, username FROM users WHERE username = $username") -const findUserByUsernameWithPwQuery = db.query("SELECT id, username, password FROM users WHERE username = $username") - -const createUserQuery = db.query("INSERT INTO users(id, username, password) VALUES ($id, $username, $password)") - function findUserByUsername(username: string, opts: { password: true }): UserWithPassword | null function findUserByUsername(username: string, { password }: { password?: boolean }): User | UserWithPassword | null { + const findUserByUsernameQuery = db.query("SELECT id, username FROM users WHERE username = $username") + const findUserByUsernameWithPwQuery = db.query("SELECT id, username, password FROM users WHERE username = $username") + const row = (password ? findUserByUsernameWithPwQuery : findUserByUsernameQuery).get({ username }) if (!row) { return null @@ -23,6 +20,7 @@ function findUserByUsername(username: string, { password }: { password?: boolean } function findUserById(userId: string): User | null { + const findUserByIdQuery = db.query("SELECT id, username FROM users WHERE id = $userId") const row = findUserByIdQuery.get({ userId }) if (!row) { return null @@ -31,6 +29,7 @@ function findUserById(userId: string): User | null { } function createUser(username: string, password: string): User { + const createUserQuery = db.query("INSERT INTO users(id, username, password) VALUES ($id, $username, $password)") const userId = ulid() createUserQuery.run({ id: userId, @@ -43,19 +42,17 @@ function createUser(username: string, password: string): User { } } -async function createDemoUser() { - const row = findUserByUsernameQuery.get({ username: DEMO_USER.username }) - if (row) { - return - } - +async function createDemoUser(): Promise { + const createUserQuery = db.query( + "INSERT OR REPLACE INTO users(id, username, password) VALUES ($id, $username, $password)", + ) const hashedPassword = await Bun.password.hash(DEMO_USER.unhashedPassword, "argon2id") - createUserQuery.run({ id: DEMO_USER.id, username: DEMO_USER.username, password: hashedPassword, }) + return DEMO_USER } export type { User } diff --git a/packages/web/.env.development b/packages/web/.env.development new file mode 100644 index 0000000..460e575 --- /dev/null +++ b/packages/web/.env.development @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8080 diff --git a/packages/web/package.json b/packages/web/package.json index 9101325..7a367bb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -17,6 +17,7 @@ "dayjs": "^1.11.13", "react": "^19.0.0", "react-dom": "^19.0.0", + "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.5", "zustand": "^5.0.4" }, diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts new file mode 100644 index 0000000..31aa04b --- /dev/null +++ b/packages/web/src/api.ts @@ -0,0 +1,55 @@ +import { type DefaultError, type UseMutationOptions, type queryOptions, useQuery } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { useEffect } from "react" + +class BadRequestError extends Error {} +class InternalError extends Error {} +class UnauthenticatedError extends Error {} + +type QueryKey = ["bookmarks", ...ReadonlyArray] + +async function fetchApi(route: string, init?: RequestInit): Promise<[TData | void, Response]> { + const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, { + ...init, + credentials: "include", + }) + switch (response.status) { + case 200: + return [await response.json(), response] + case 204: + return [undefined, response] + case 400: + throw new BadRequestError() + case 401: + throw new UnauthenticatedError() + default: + throw new InternalError() + } +} + +function useAuthenticatedQuery(queryKey: QueryKey, fn: () => Promise) { + const query = useQuery({ + queryKey, + queryFn: () => fn(), + retry: (_, error) => !(error instanceof UnauthenticatedError), + }) + + const navigate = useNavigate() + + useEffect(() => { + if (query.error && query.error instanceof UnauthenticatedError) { + navigate({ to: "/login", replace: true }) + } + }, [query.error, navigate]) + + return query +} + +function mutationOptions( + options: UseMutationOptions, +): UseMutationOptions { + return options +} + +export { BadRequestError, InternalError, UnauthenticatedError, fetchApi, useAuthenticatedQuery, mutationOptions } +export type { QueryKey } diff --git a/packages/web/src/app/-route-tree.gen.ts b/packages/web/src/app/-route-tree.gen.ts index af05d63..69fe126 100644 --- a/packages/web/src/app/-route-tree.gen.ts +++ b/packages/web/src/app/-route-tree.gen.ts @@ -11,11 +11,18 @@ // Import Routes import { Route as rootRoute } from "./__root" +import { Route as LoginImport } from "./login" import { Route as BookmarksImport } from "./bookmarks" import { Route as IndexImport } from "./index" // Create/Update Routes +const LoginRoute = LoginImport.update({ + id: "/login", + path: "/login", + getParentRoute: () => rootRoute, +} as any) + const BookmarksRoute = BookmarksImport.update({ id: "/bookmarks", path: "/bookmarks", @@ -46,6 +53,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BookmarksImport parentRoute: typeof rootRoute } + "/login": { + id: "/login" + path: "/login" + fullPath: "/login" + preLoaderRoute: typeof LoginImport + parentRoute: typeof rootRoute + } } } @@ -54,36 +68,41 @@ declare module "@tanstack/react-router" { export interface FileRoutesByFullPath { "/": typeof IndexRoute "/bookmarks": typeof BookmarksRoute + "/login": typeof LoginRoute } export interface FileRoutesByTo { "/": typeof IndexRoute "/bookmarks": typeof BookmarksRoute + "/login": typeof LoginRoute } export interface FileRoutesById { __root__: typeof rootRoute "/": typeof IndexRoute "/bookmarks": typeof BookmarksRoute + "/login": typeof LoginRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: "/" | "/bookmarks" + fullPaths: "/" | "/bookmarks" | "/login" fileRoutesByTo: FileRoutesByTo - to: "/" | "/bookmarks" - id: "__root__" | "/" | "/bookmarks" + to: "/" | "/bookmarks" | "/login" + id: "__root__" | "/" | "/bookmarks" | "/login" fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute BookmarksRoute: typeof BookmarksRoute + LoginRoute: typeof LoginRoute } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BookmarksRoute: BookmarksRoute, + LoginRoute: LoginRoute, } export const routeTree = rootRoute @@ -97,7 +116,8 @@ export const routeTree = rootRoute "filePath": "__root.tsx", "children": [ "/", - "/bookmarks" + "/bookmarks", + "/login" ] }, "/": { @@ -105,6 +125,9 @@ export const routeTree = rootRoute }, "/bookmarks": { "filePath": "bookmarks.tsx" + }, + "/login": { + "filePath": "login.tsx" } } } diff --git a/packages/web/src/app/bookmarks.tsx b/packages/web/src/app/bookmarks.tsx index 27800ee..ba8e9d1 100644 --- a/packages/web/src/app/bookmarks.tsx +++ b/packages/web/src/app/bookmarks.tsx @@ -3,22 +3,11 @@ import { createFileRoute } from "@tanstack/react-router" import clsx from "clsx" import { useEffect } from "react" import { create } from "zustand" +import { fetchApi, useAuthenticatedQuery } from "~/api" import { Button } from "~/components/button" - -const testBookmarks: LinkBookmark[] = [ - { - kind: "link", - id: "1", - title: "Running a Docker container as a non-root user", - url: "https://test.website.com/article/123", - }, - { - kind: "link", - id: "2", - title: "Running a Docker container as a non-root user", - url: "https://test.website.com/article/123", - }, -] +import { useDeleteBookmark } from "~/bookmark/api" +import { useMnemonics } from "~/hooks/use-mnemonics" +import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" const LAYOUT_MODE = { popup: "popup", @@ -26,25 +15,45 @@ const LAYOUT_MODE = { } as const type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE] +enum ActiveDialog { + None = "None", + AddBookmark = "AddBookmark", + DeleteBookmark = "DeleteBookmark", +} + interface BookmarkPageState { - bookmarks: LinkBookmark[] + bookmarkCount: number + selectedBookmarkId: string selectedBookmarkIndex: number isBookmarkItemExpanded: boolean isBookmarkPreviewOpened: boolean + bookmarkToBeDeleted: LinkBookmark | null layoutMode: LayoutMode + activeDialog: ActiveDialog + setActiveDialog: (dialog: ActiveDialog) => void setBookmarkItemExpanded: (isExpanded: boolean) => void setBookmarkPreviewOpened: (isOpened: boolean) => void setLayoutMode: (mode: LayoutMode) => void - selectBookmarkAt: (index: number) => void + selectBookmark: (bookmark: LinkBookmark, index: number) => void + reconcileSelection: (bookmarks: LinkBookmark[]) => void + markBookmarkForDeletion: (bookmark: LinkBookmark | null) => void } const useBookmarkPageStore = create()((set, get) => ({ - bookmarks: testBookmarks, + bookmarkCount: 0, + bookmarks: [], + selectedBookmarkId: "", selectedBookmarkIndex: 0, isBookmarkItemExpanded: false, isBookmarkPreviewOpened: false, + bookmarkToBeDeleted: null, layoutMode: LAYOUT_MODE.popup, + activeDialog: ActiveDialog.None, + + setActiveDialog(dialog: ActiveDialog) { + set({ activeDialog: dialog }) + }, setBookmarkItemExpanded(isExpanded: boolean) { set({ isBookmarkItemExpanded: isExpanded }) @@ -58,46 +67,34 @@ const useBookmarkPageStore = create()((set, get) => ({ set({ layoutMode: mode }) }, - selectBookmarkAt(index: number) { - const bookmarks = get().bookmarks - if (index >= 0 && index < bookmarks.length) { - set({ selectedBookmarkIndex: index }) + selectBookmark(bookmark: LinkBookmark, index: number) { + set({ selectedBookmarkId: bookmark.id, selectedBookmarkIndex: index }) + }, + + reconcileSelection(bookmarks: LinkBookmark[]) { + const { selectedBookmarkId, selectedBookmarkIndex } = get() + const newIndex = bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) + if (newIndex !== selectedBookmarkIndex) { + if (newIndex >= 0) { + set({ selectedBookmarkIndex: newIndex }) + } else if (selectedBookmarkIndex >= bookmarks.length - 1) { + set({ selectedBookmarkIndex: bookmarks.length - 1 }) + } else if (selectedBookmarkIndex === 0) { + set({ selectedBookmarkIndex: 0 }) + } else { + set({ selectedBookmarkIndex: selectedBookmarkIndex + 1 }) + } } }, + + markBookmarkForDeletion(bookmark: LinkBookmark | null) { + set({ bookmarkToBeDeleted: bookmark }) + }, })) function Page() { const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode) - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - const state = useBookmarkPageStore.getState() - - switch (event.key) { - case "ArrowDown": - state.selectBookmarkAt(state.selectedBookmarkIndex + 1) - break - case "ArrowUp": - state.selectBookmarkAt(state.selectedBookmarkIndex - 1) - break - case "ArrowLeft": - state.setBookmarkItemExpanded(false) - break - case "ArrowRight": - state.setBookmarkItemExpanded(true) - break - default: - break - } - } - - window.addEventListener("keydown", onKeyDown) - - return () => { - window.removeEventListener("keydown", onKeyDown) - } - }, []) - useEffect(() => { function mediaQueryListener(this: MediaQueryList) { if (this.matches) { @@ -118,23 +115,21 @@ function Page() { }, [setLayoutMode]) return ( -
+
-
+

YOUR BOOKMARKS

-
- {testBookmarks.map((bookmark, i) => ( - - ))} -
+
+ +
) } @@ -155,6 +150,142 @@ function Main({ children }: React.PropsWithChildren) { ) } +function PageDialog() { + const dialog = useBookmarkPageStore((state) => state.activeDialog) + switch (dialog) { + case ActiveDialog.None: + return null + case ActiveDialog.AddBookmark: + return null + case ActiveDialog.DeleteBookmark: + return + } +} + +function DeleteBookmarkDialog() { + // biome-ignore lint/style/noNonNullAssertion: this cannot be null when delete bookmark dialog is visible + const bookmark = useBookmarkPageStore((state) => state.bookmarkToBeDeleted!) + const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) + const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion) + const deleteBookmarkMutation = useDeleteBookmark() + + useMnemonics( + { + p: proceed, + c: cancel, + }, + { active: true }, + ) + + async function proceed() { + try { + await deleteBookmarkMutation.mutateAsync({ bookmark }) + setActiveDialog(ActiveDialog.None) + markBookmarkForDeletion(null) + } catch (error) { + console.error(error) + } + } + + function cancel() { + setActiveDialog(ActiveDialog.None) + markBookmarkForDeletion(null) + } + + return ( + + CONFIRM + + The bookmark titled{" "} + + "{bookmark.title}" + {" "} + will be deleted. Proceed? + + + + + + + ) +} + +function BookmarkListSection() { + const { + data: bookmarks, + error, + status, + } = useAuthenticatedQuery(["bookmarks"], () => fetchApi("/bookmarks").then(([data]) => data)) + + switch (status) { + case "pending": + return

Loading...

+ case "success": + return + default: + return null + } +} + +function BookmarkList({ bookmarks }: { bookmarks: LinkBookmark[] }) { + const reconcileSelection = useBookmarkPageStore((state) => state.reconcileSelection) + + useEffect(() => { + reconcileSelection(bookmarks) + }, [bookmarks, reconcileSelection]) + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const state = useBookmarkPageStore.getState() + + switch (event.key) { + case "ArrowDown": { + const nextIndex = state.selectedBookmarkIndex + 1 + if (nextIndex < bookmarks.length) { + state.selectBookmark(bookmarks[nextIndex], nextIndex) + } + break + } + case "ArrowUp": { + const prevIndex = state.selectedBookmarkIndex - 1 + if (prevIndex >= 0) { + state.selectBookmark(bookmarks[prevIndex], prevIndex) + } + break + } + case "ArrowLeft": + state.setBookmarkItemExpanded(false) + break + case "ArrowRight": + state.setBookmarkItemExpanded(true) + break + default: + break + } + } + + window.addEventListener("keydown", onKeyDown) + + return () => { + window.removeEventListener("keydown", onKeyDown) + } + }, [bookmarks]) + + return ( +
+ {bookmarks.length === 0 ? ( +

You have not saved any bookmark!

+ ) : ( + bookmarks.map((bookmark, i) => ) + )} +
+ ) +} + function BookmarkPreview() { const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened) const layoutMode = useBookmarkPageStore((state) => state.layoutMode) @@ -179,12 +310,28 @@ function BookmarkPreview() { function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) { const url = new URL(bookmark.url) - const selectedBookmark = useBookmarkPageStore((state) => state.bookmarks[state.selectedBookmarkIndex]) - const isSelected = selectedBookmark.id === bookmark.id + const selectedBookmarkIndex = useBookmarkPageStore((state) => state.selectedBookmarkIndex) const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded) + const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog) const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded) - const selectBookmarkAt = useBookmarkPageStore((state) => state.selectBookmarkAt) + const selectBookmark = useBookmarkPageStore((state) => state.selectBookmark) const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened) + const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion) + const isSelected = selectedBookmarkIndex === index + + useMnemonics( + { + d: deleteItem, + }, + { active: isSelected }, + ) + + function deleteItem() { + if (isSelected) { + markBookmarkForDeletion(bookmark) + setActiveDialog(ActiveDialog.DeleteBookmark) + } + } function expandOrOpenPreview() { setBookmarkItemExpanded(true) @@ -202,7 +349,7 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: })} onMouseEnter={() => { if (!isBookmarkItemExpanded) { - selectBookmarkAt(index) + selectBookmark(bookmark, index) } }} > @@ -232,10 +379,10 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:

#dev #devops #devops #devops #devops #devops #devops

- -   @@ -279,6 +426,7 @@ function OpenBookmarkPreviewButton() { return ( + +
+ ) +} + export const Route = createFileRoute("/bookmarks")({ component: Page, }) diff --git a/packages/web/src/app/index.tsx b/packages/web/src/app/index.tsx index 1da826b..d0e89b6 100644 --- a/packages/web/src/app/index.tsx +++ b/packages/web/src/app/index.tsx @@ -13,12 +13,12 @@ function Index() {

BOOKMARK MANAGER

- LOGIN + LOGIN SIGN UP
-
+

WHAT IS MARKONE?
@@ -52,11 +52,15 @@ function DemoButton() { const navigate = useNavigate() async function startDemo() { - await loginMutation.mutateAsync({ - username: DEMO_USER.username, - password: DEMO_USER.unhashedPassword, - }) - navigate({ to: "/bookmarks" }) + try { + const res = await loginMutation.mutateAsync({ + username: DEMO_USER.username, + password: DEMO_USER.unhashedPassword, + }) + if (res.status === 200) { + navigate({ to: "/bookmarks" }) + } + } catch {} } return ( diff --git a/packages/web/src/app/login.tsx b/packages/web/src/app/login.tsx new file mode 100644 index 0000000..6ff96d5 --- /dev/null +++ b/packages/web/src/app/login.tsx @@ -0,0 +1,80 @@ +import { useNavigate } from "@tanstack/react-router" +import { useState } from "react" +import { createFileRoute } from "@tanstack/react-router" +import { FormField } from "~/components/form-field" +import { Button } from "~/components/button" +import { useLogin } from "~/auth" + +function Page() { + const [error, setError] = useState("") + const navigate = useNavigate() + const loginMutation = useLogin() + + async function submitLoginForm(event: React.FormEvent) { + event.preventDefault() + + if (!event.currentTarget) { + return + } + + const formData = new FormData(event.currentTarget) + + const username = formData.get("username") + const password = formData.get("password") + + if (typeof username === "string" && typeof password === "string") { + try { + const res = await loginMutation.mutateAsync({ + username, + password, + }) + switch (res.status) { + case 200: + navigate({ to: "/bookmarks" }) + break + + case 401: + setError("Incorrect username or password!") + break + + default: + setError(`Server returned an unexpected response (status ${res.status}.)`) + break + } + } catch (error) { + setError("Unable to connect to the server!") + } + } + } + + return ( +

+
+

+ MARKONE +
+ BOOKMARK MANAGER +

+
+
+

LOG IN TO YOUR ACCOUNT

+ {error ? ( +
+

{error}

+
+ ) : null} +
+ + + + +
+
+ ) +} + +export const Route = createFileRoute("/login")({ + component: Page, +}) diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 6c3676d..827bf61 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -3,9 +3,10 @@ import { useMutation } from "@tanstack/react-query" function useLogin() { return useMutation({ mutationFn: async (creds: { username: string; password: string }) => { - await fetch("/api/login", { + return await fetch(`${import.meta.env.VITE_API_URL}/api/login`, { method: "POST", body: JSON.stringify(creds), + credentials: "include", }) }, }) diff --git a/packages/web/src/bookmark/api.ts b/packages/web/src/bookmark/api.ts new file mode 100644 index 0000000..be27670 --- /dev/null +++ b/packages/web/src/bookmark/api.ts @@ -0,0 +1,28 @@ +import type { Bookmark } from "@markone/core/bookmark" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { UnauthenticatedError, fetchApi } from "~/api" + +function useDeleteBookmark() { + const navigate = useNavigate() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ bookmark }: { bookmark: Bookmark }) => + fetchApi(`/bookmark/${bookmark.id}`, { + method: "DELETE", + }), + onError: (error) => { + if (error instanceof UnauthenticatedError) { + navigate({ to: "/login", replace: true }) + } + }, + onSuccess: (_, { bookmark }) => { + queryClient.setQueryData(["bookmarks"], (bookmarks: Bookmark[]) => + bookmarks.filter((it) => it.id !== bookmark.id), + ) + }, + }) +} + +export { useDeleteBookmark } diff --git a/packages/web/src/components/button.tsx b/packages/web/src/components/button.tsx index 2587758..ad8e847 100644 --- a/packages/web/src/components/button.tsx +++ b/packages/web/src/components/button.tsx @@ -1,13 +1,27 @@ -import clsx from "clsx" +import { twMerge } from "tailwind-merge" + +const VARIANT_CLASSES = { + light: + "px-4 py-2 md:py-0 font-bold border border-2 border-b-4 border-stone-200 enabled:active:bg-stone-200 enabled:active:text-stone-800 enabled:active:border-b-1 enabled:hover:bg-stone-600 focus:bg-stone-600 focus:ring-0 focus:outline-none enabled:active:translate-y-0.5", + normal: + "px-4 py-2 md:py-0 font-bold border border-2 border-b-4 border-stone-800 dark:border-stone-200 enabled:active:bg-stone-800 dark:enabled:active:bg-stone-200 enabled:active:text-stone-200 dark:enabled:active:text-stone-800 enabled:active:border-b-1 enabled:hover:bg-stone-400 dark:enabled:hover:bg-stone-600 focus:bg-stone-400 dark:focus:bg-stone-600 focus:ring-0 focus:outline-none enabled:active:translate-y-0.5", +} as const function Button({ className, + variant = "normal", + disabled, ...props -}: React.DetailedHTMLProps, HTMLButtonElement>) { +}: React.DetailedHTMLProps, HTMLButtonElement> & { + variant?: keyof typeof VARIANT_CLASSES +}) { return (
+ ) +} + +function DialogTitle({ children }: React.PropsWithChildren) { + return

{children}

+} + +function DialogBody({ children }: React.PropsWithChildren) { + return
{children}
+} + +function DialogActionRow({ children }: React.PropsWithChildren) { + return
{children}
+} + +export { Dialog, DialogTitle, DialogBody, DialogActionRow } diff --git a/packages/web/src/components/form-field.tsx b/packages/web/src/components/form-field.tsx new file mode 100644 index 0000000..ff56975 --- /dev/null +++ b/packages/web/src/components/form-field.tsx @@ -0,0 +1,32 @@ +import { clsx } from "clsx" +import { useId } from "react" + +interface FormFieldProps { + name: string + label: string + type: React.HTMLInputTypeAttribute + className?: string +} + +function FormField({ name, label, type, className }: FormFieldProps) { + const id = useId() + return ( +
+ + +
+ ) +} + +export { FormField } diff --git a/packages/web/src/hooks/use-mnemonics.ts b/packages/web/src/hooks/use-mnemonics.ts new file mode 100644 index 0000000..e2eb418 --- /dev/null +++ b/packages/web/src/hooks/use-mnemonics.ts @@ -0,0 +1,17 @@ +import { useEffect } from "react" + +function useMnemonics(mnemonicMap: Record void>, { active }: { active: boolean }) { + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + mnemonicMap[event.key]?.(event) + } + if (active) { + document.addEventListener("keydown", onKeyDown) + } + return () => { + document.removeEventListener("keydown", onKeyDown) + } + }, [mnemonicMap, active]) +} + +export { useMnemonics } diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 63e64b8..ea731eb 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -1,9 +1,19 @@ @import "tailwindcss"; :root { - font-family: monospace; + font-family: monospace; } body { - @apply bg-stone-200 dark:bg-stone-900 text-stone-800 dark:text-stone-300; + @apply bg-stone-200 dark:bg-stone-900 text-stone-800 dark:text-stone-300; +} + +.stripes { + background: repeating-linear-gradient( + -45deg, + var(--color-stone-400), + var(--color-stone-400) 4px, + var(--color-stone-300) 4px, + var(--color-stone-300) 12px + ); } diff --git a/packages/web/src/typedefs/react-query.d.ts b/packages/web/src/typedefs/react-query.d.ts new file mode 100644 index 0000000..4c64b3f --- /dev/null +++ b/packages/web/src/typedefs/react-query.d.ts @@ -0,0 +1,8 @@ +import "@tanstack/react-query" +import type { QueryKey } from "~/api" + +declare module "@tanstack/react-query" { + interface Register { + queryKey: QueryKey + } +}