kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: :fire: migrating to sessions, using file routes, adding auth provider

Andrej d6f8ecce 73ddd6d0

+768 -267
+21
apps/api/drizzle/0002_flowery_secret_warriors.sql
··· 1 + CREATE TABLE `session` ( 2 + `id` text PRIMARY KEY NOT NULL, 3 + `user_id` text NOT NULL, 4 + `expires_at` integer NOT NULL, 5 + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 6 + ); 7 + --> statement-breakpoint 8 + PRAGMA foreign_keys=OFF;--> statement-breakpoint 9 + CREATE TABLE `__new_user` ( 10 + `id` text PRIMARY KEY NOT NULL, 11 + `name` text NOT NULL, 12 + `password` text NOT NULL, 13 + `email` text NOT NULL, 14 + `created_at` integer DEFAULT '"2025-01-04T22:24:29.828Z"' NOT NULL 15 + ); 16 + --> statement-breakpoint 17 + INSERT INTO `__new_user`("id", "name", "password", "email", "created_at") SELECT "id", "name", "password", "email", "created_at" FROM `user`;--> statement-breakpoint 18 + DROP TABLE `user`;--> statement-breakpoint 19 + ALTER TABLE `__new_user` RENAME TO `user`;--> statement-breakpoint 20 + PRAGMA foreign_keys=ON;--> statement-breakpoint 21 + CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);
+111
apps/api/drizzle/meta/0002_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "1c74d9e8-3e5e-4199-9175-7514767a6912", 5 + "prevId": "e1a9d8d8-e2dd-4fa3-864a-b396b6f6034e", 6 + "tables": { 7 + "session": { 8 + "name": "session", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "user_id": { 18 + "name": "user_id", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "expires_at": { 25 + "name": "expires_at", 26 + "type": "integer", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + } 31 + }, 32 + "indexes": {}, 33 + "foreignKeys": { 34 + "session_user_id_user_id_fk": { 35 + "name": "session_user_id_user_id_fk", 36 + "tableFrom": "session", 37 + "tableTo": "user", 38 + "columnsFrom": ["user_id"], 39 + "columnsTo": ["id"], 40 + "onDelete": "no action", 41 + "onUpdate": "no action" 42 + } 43 + }, 44 + "compositePrimaryKeys": {}, 45 + "uniqueConstraints": {}, 46 + "checkConstraints": {} 47 + }, 48 + "user": { 49 + "name": "user", 50 + "columns": { 51 + "id": { 52 + "name": "id", 53 + "type": "text", 54 + "primaryKey": true, 55 + "notNull": true, 56 + "autoincrement": false 57 + }, 58 + "name": { 59 + "name": "name", 60 + "type": "text", 61 + "primaryKey": false, 62 + "notNull": true, 63 + "autoincrement": false 64 + }, 65 + "password": { 66 + "name": "password", 67 + "type": "text", 68 + "primaryKey": false, 69 + "notNull": true, 70 + "autoincrement": false 71 + }, 72 + "email": { 73 + "name": "email", 74 + "type": "text", 75 + "primaryKey": false, 76 + "notNull": true, 77 + "autoincrement": false 78 + }, 79 + "created_at": { 80 + "name": "created_at", 81 + "type": "integer", 82 + "primaryKey": false, 83 + "notNull": true, 84 + "autoincrement": false, 85 + "default": "'\"2025-01-04T22:24:29.828Z\"'" 86 + } 87 + }, 88 + "indexes": { 89 + "user_email_unique": { 90 + "name": "user_email_unique", 91 + "columns": ["email"], 92 + "isUnique": true 93 + } 94 + }, 95 + "foreignKeys": {}, 96 + "compositePrimaryKeys": {}, 97 + "uniqueConstraints": {}, 98 + "checkConstraints": {} 99 + } 100 + }, 101 + "views": {}, 102 + "enums": {}, 103 + "_meta": { 104 + "schemas": {}, 105 + "tables": {}, 106 + "columns": {} 107 + }, 108 + "internal": { 109 + "indexes": {} 110 + } 111 + }
+7
apps/api/drizzle/meta/_journal.json
··· 15 15 "when": 1735996197501, 16 16 "tag": "0001_melted_whizzer", 17 17 "breakpoints": true 18 + }, 19 + { 20 + "idx": 2, 21 + "version": "6", 22 + "when": 1736029469835, 23 + "tag": "0002_flowery_secret_warriors", 24 + "breakpoints": true 18 25 } 19 26 ] 20 27 }
+4 -2
apps/api/package.json
··· 10 10 "@elysiajs/cors": "^1.2.0", 11 11 "@elysiajs/jwt": "^1.2.0", 12 12 "@elysiajs/websocket": "^0.2.8", 13 + "@kaneo/typescript-config": "workspace:*", 14 + "@oslojs/crypto": "^1.0.1", 15 + "@oslojs/encoding": "^1.1.0", 13 16 "@paralleldrive/cuid2": "^2.2.2", 14 17 "better-sqlite3": "^11.7.0", 15 18 "drizzle-kit": "^0.30.1", 16 19 "drizzle-orm": "^0.38.3", 17 20 "drizzle-typebox": "^0.2.1", 18 - "elysia": "latest", 19 - "@kaneo/typescript-config": "workspace:*" 21 + "elysia": "latest" 20 22 }, 21 23 "devDependencies": { 22 24 "bun-types": "latest"
+14
apps/api/src/database/schema.ts
··· 1 1 import { createId } from "@paralleldrive/cuid2"; 2 + import type { InferSelectModel } from "drizzle-orm"; 2 3 import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 4 4 5 export const userTable = sqliteTable("user", { ··· 12 13 .default(new Date()) 13 14 .notNull(), 14 15 }); 16 + 17 + export const sessionTable = sqliteTable("session", { 18 + id: text("id").primaryKey(), 19 + userId: text("user_id") 20 + .notNull() 21 + .references(() => userTable.id), 22 + expiresAt: integer("expires_at", { 23 + mode: "timestamp", 24 + }).notNull(), 25 + }); 26 + 27 + export type User = InferSelectModel<typeof userTable>; 28 + export type Session = InferSelectModel<typeof sessionTable>;
+13 -24
apps/api/src/index.ts
··· 1 1 import { cors } from "@elysiajs/cors"; 2 2 import { Elysia } from "elysia"; 3 3 import user from "./user"; 4 - import { REFRESH_TOKEN_EXPIRY } from "./user/constants"; 5 - import createToken from "./user/utils/create-token"; 4 + import { validateSessionToken } from "./user/controllers/validate-session-token"; 6 5 7 6 const app = new Elysia() 8 7 .use(cors()) 9 8 .use(user) 10 9 .guard({ 11 - async beforeHandle({ 12 - set, 13 - accessJwt, 14 - refreshJwt, 15 - cookie: { accessToken, refreshToken }, 16 - }) { 17 - const decodedAccessToken = await accessJwt.verify(accessToken.value); 18 - const decodedRefreshToken = await refreshJwt.verify(refreshToken.value); 19 - 20 - if (!decodedAccessToken) { 10 + async beforeHandle({ set, cookie: { session } }) { 11 + if (!session?.value) { 21 12 set.status = "Unauthorized"; 22 13 23 14 return set.status; 24 15 } 25 16 26 - if (!decodedRefreshToken) { 27 - const refreshToken = await createToken({ 28 - jwt: refreshJwt, 29 - expires: REFRESH_TOKEN_EXPIRY, 30 - payload: { 31 - id: String(decodedAccessToken.id), 32 - }, 33 - }); 17 + const { user, session: validatedSession } = await validateSessionToken( 18 + session.value, 19 + ); 20 + 21 + if (!user || !validatedSession) { 22 + set.status = "Unauthorized"; 34 23 35 - if (set.cookie) set.cookie.refreshToken = refreshToken; 24 + return set.status; 36 25 } 37 26 }, 38 27 }) 39 - .get("/me", async ({ refreshJwt, cookie: { refreshToken } }) => { 40 - const profile = await refreshJwt.verify(refreshToken.value); 28 + .get("/me", async ({ cookie: { session } }) => { 29 + const { user } = await validateSessionToken(session.value ?? ""); 41 30 42 - return profile; 31 + return user; 43 32 }) 44 33 .onError(({ code, error }) => { 45 34 switch (code) {
-7
apps/api/src/user/constants.ts
··· 1 - export const ACCESS_TOKEN_EXPIRY = new Date( 2 - Date.now() + 30 * 24 * 60 * 60 * 1000, // 30d 3 - ); 4 - 5 - export const REFRESH_TOKEN_EXPIRY = new Date( 6 - new Date(Date.now() + 15 * 60 * 1000), // 15m 7 - );
+18
apps/api/src/user/controllers/create-session.ts
··· 1 + import { sha256 } from "@oslojs/crypto/sha2"; 2 + import { encodeHexLowerCase } from "@oslojs/encoding"; 3 + import db from "../../database"; 4 + import type { Session } from "../../database/schema"; 5 + import { sessionTable } from "../../database/schema"; 6 + 7 + async function createSession(token: string, userId: string): Promise<Session> { 8 + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 9 + const session: Session = { 10 + id: sessionId, 11 + userId, 12 + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 13 + }; 14 + await db.insert(sessionTable).values(session); 15 + return session; 16 + } 17 + 18 + export default createSession;
+9
apps/api/src/user/controllers/invalidate-session.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../../database"; 3 + import { sessionTable } from "../../database/schema"; 4 + 5 + async function invalidateSession(sessionId: string): Promise<void> { 6 + await db.delete(sessionTable).where(eq(sessionTable.id, sessionId)); 7 + } 8 + 9 + export default invalidateSession;
+45
apps/api/src/user/controllers/validate-session-token.ts
··· 1 + import { sha256 } from "@oslojs/crypto/sha2"; 2 + import { encodeHexLowerCase } from "@oslojs/encoding"; 3 + import { eq } from "drizzle-orm"; 4 + import db from "../../database"; 5 + import { sessionTable, userTable } from "../../database/schema"; 6 + import type { SessionValidationResult } from "../types"; 7 + 8 + export async function validateSessionToken( 9 + token: string, 10 + ): Promise<SessionValidationResult> { 11 + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 12 + const result = await db 13 + .select({ user: userTable, session: sessionTable }) 14 + .from(sessionTable) 15 + .innerJoin(userTable, eq(sessionTable.userId, userTable.id)) 16 + .where(eq(sessionTable.id, sessionId)); 17 + 18 + if (result.length < 1) { 19 + return { session: null, user: null }; 20 + } 21 + 22 + const { user, session } = result[0]; 23 + 24 + const isSessionExpired = Date.now() >= session.expiresAt.getTime(); 25 + 26 + if (isSessionExpired) { 27 + await db.delete(sessionTable).where(eq(sessionTable.id, session.id)); 28 + return { session: null, user: null }; 29 + } 30 + 31 + const isSessionHalfWayExpired = 32 + Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15; 33 + 34 + if (isSessionHalfWayExpired) { 35 + session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 36 + await db 37 + .update(sessionTable) 38 + .set({ 39 + expiresAt: session.expiresAt, 40 + }) 41 + .where(eq(sessionTable.id, session.id)); 42 + } 43 + 44 + return { session, user }; 45 + }
+34 -50
apps/api/src/user/index.ts
··· 1 1 import { jwt } from "@elysiajs/jwt"; 2 2 import { Elysia, t } from "elysia"; 3 - import { ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY } from "./constants"; 3 + import createSession from "./controllers/create-session"; 4 + import invalidateSession from "./controllers/invalidate-session"; 4 5 import signIn from "./controllers/sign-in"; 5 6 import signUp from "./controllers/sign-up"; 6 7 import { signInUserSchema, signUpUserSchema } from "./db/queries"; 7 8 import { UserErrors } from "./errors"; 8 - import createToken from "./utils/create-token"; 9 + import generateSessionToken from "./utils/generate-session-token"; 9 10 10 11 const user = new Elysia({ prefix: "/user" }) 11 12 .use( 12 13 jwt({ 13 - name: "accessJwt", 14 + name: "sessionToken", 14 15 secret: process.env.JWT_ACCESS ?? "", 15 16 }), 16 17 ) 17 - .use( 18 - jwt({ 19 - name: "refreshJwt", 20 - secret: process.env.JWT_REFRESH ?? "", 21 - }), 22 - ) 23 18 .post( 24 19 "/sign-in", 25 - async ({ body, accessJwt, refreshJwt, set }) => { 20 + async ({ body, set }) => { 26 21 const user = await signIn(body); 27 22 28 - const accessToken = await createToken({ 29 - expires: ACCESS_TOKEN_EXPIRY, 30 - jwt: accessJwt, 31 - payload: { 32 - id: user.id, 33 - }, 34 - }); 35 - 36 - const refreshToken = await createToken({ 37 - expires: REFRESH_TOKEN_EXPIRY, 38 - jwt: refreshJwt, 39 - payload: { 40 - id: user.id, 41 - }, 42 - }); 43 - 23 + const token = generateSessionToken(); 24 + const session = await createSession(token, user.id); 44 25 set.cookie = { 45 - accessToken, 46 - refreshToken, 26 + session: { 27 + value: token, 28 + httpOnly: true, 29 + path: "/", 30 + secure: process.env.NODE_ENV === "production", 31 + sameSite: "lax", 32 + expires: session.expiresAt, 33 + }, 47 34 }; 48 35 49 - return { 50 - user, 51 - }; 36 + return user; 52 37 }, 53 38 { 54 39 body: t.Omit(signInUserSchema, ["id", "name", "createdAt"]), ··· 56 41 ) 57 42 .post( 58 43 "/sign-up", 59 - async ({ body, accessJwt, refreshJwt, set }) => { 44 + async ({ body, set }) => { 60 45 const user = await signUp(body); 61 46 62 47 if (!user) throw new Error(UserErrors.FailedToCreateAnAccount); 63 48 64 - const accessToken = await createToken({ 65 - expires: ACCESS_TOKEN_EXPIRY, 66 - jwt: accessJwt, 67 - payload: { 68 - id: user.id, 69 - }, 70 - }); 71 - 72 - const refreshToken = await createToken({ 73 - expires: REFRESH_TOKEN_EXPIRY, 74 - jwt: refreshJwt, 75 - payload: { 76 - id: user.id, 77 - }, 78 - }); 79 - 49 + const token = generateSessionToken(); 50 + const session = await createSession(token, user.id); 80 51 set.cookie = { 81 - accessToken, 82 - refreshToken, 52 + session: { 53 + value: token, 54 + httpOnly: true, 55 + path: "/", 56 + secure: process.env.NODE_ENV === "production", 57 + sameSite: "lax", 58 + expires: session.expiresAt, 59 + }, 83 60 }; 84 61 85 62 return user; ··· 88 65 body: signUpUserSchema, 89 66 }, 90 67 ) 68 + .post("/sign-out", async ({ cookie, cookie: { session } }) => { 69 + await invalidateSession(session?.value ?? ""); 70 + session.remove(); 71 + 72 + // biome-ignore lint/performance/noDelete: https://elysiajs.com/patterns/cookie#remove 73 + delete cookie.session; 74 + }) 91 75 .onError(({ code, error }) => { 92 76 switch (code) { 93 77 case "VALIDATION":
+5
apps/api/src/user/types.ts
··· 1 + import type { Session, User } from "../database/schema"; 2 + 3 + export type SessionValidationResult = 4 + | { session: Session; user: User } 5 + | { session: null; user: null };
-22
apps/api/src/user/utils/create-token.ts
··· 1 - import type { jwt as jwtInstance } from "@elysiajs/jwt"; 2 - import type { Static } from "elysia"; 3 - import type { signInUserSchema } from "../db/queries"; 4 - 5 - const createToken = async ({ 6 - jwt, 7 - payload, 8 - expires, 9 - }: { 10 - expires: Date; 11 - jwt: ReturnType<typeof jwtInstance>; 12 - payload: Pick<Static<typeof signInUserSchema>, "id">; 13 - }) => { 14 - return { 15 - value: await jwt.sign(payload), 16 - httpOnly: true, 17 - path: "/", 18 - expires, 19 - }; 20 - }; 21 - 22 - export default createToken;
+10
apps/api/src/user/utils/generate-session-token.ts
··· 1 + import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; 2 + 3 + function generateSessionToken(): string { 4 + const bytes = new Uint8Array(20); 5 + crypto.getRandomValues(bytes); 6 + const token = encodeBase32LowerCaseNoPadding(bytes); 7 + return token; 8 + } 9 + 10 + export default generateSessionToken;
+2 -2
apps/web/index.html
··· 3 3 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 6 + <link rel="icon" type="image/svg+xml" href="/logo.svg" /> 7 7 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 - <title>Vite + React + TS</title> 8 + <title>Kaneo</title> 9 9 </head> 10 10 11 11 <body>
+1 -1
apps/web/package.json
··· 25 25 "react-hook-form": "^7.54.2", 26 26 "tailwind-merge": "^2.6.0", 27 27 "tailwindcss-animate": "^1.0.7", 28 - "zod": "link:@hookform/resolvers/zod", 28 + "zod": "3.24.1", 29 29 "zustand": "^5.0.2" 30 30 }, 31 31 "devDependencies": {
+25
apps/web/public/logo.svg
··· 1 + <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <g filter="url(#filter0_d_1_14)"> 3 + <rect x="2" y="1" width="32" height="32" rx="4" fill="url(#paint0_linear_1_14)"/> 4 + <path d="M15 8H10C9.44772 8 9 8.44772 9 9V14C9 14.5523 9.44772 15 10 15H15C15.5523 15 16 14.5523 16 14V9C16 8.44772 15.5523 8 15 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 5 + <path d="M15 19H10C9.44772 19 9 19.4477 9 20V25C9 25.5523 9.44772 26 10 26H15C15.5523 26 16 25.5523 16 25V20C16 19.4477 15.5523 19 15 19Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 6 + <path d="M26 8H21C20.4477 8 20 8.44772 20 9V14C20 14.5523 20.4477 15 21 15H26C26.5523 15 27 14.5523 27 14V9C27 8.44772 26.5523 8 26 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 7 + <path d="M26 19H21C20.4477 19 20 19.4477 20 20V25C20 25.5523 20.4477 26 21 26H26C26.5523 26 27 25.5523 27 25V20C27 19.4477 26.5523 19 26 19Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> 8 + </g> 9 + <defs> 10 + <filter id="filter0_d_1_14" x="0" y="0" width="36" height="36" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 11 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 12 + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> 13 + <feOffset dy="1"/> 14 + <feGaussianBlur stdDeviation="1"/> 15 + <feComposite in2="hardAlpha" operator="out"/> 16 + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/> 17 + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_14"/> 18 + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_14" result="shape"/> 19 + </filter> 20 + <linearGradient id="paint0_linear_1_14" x1="18" y1="1" x2="18" y2="33" gradientUnits="userSpaceOnUse"> 21 + <stop stop-color="#6366F1"/> 22 + <stop offset="1" stop-color="#A855F7"/> 23 + </linearGradient> 24 + </defs> 25 + </svg>
-1
apps/web/public/vite.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
+1 -1
apps/web/src/components/auth/layout.tsx
··· 9 9 10 10 export function AuthLayout({ children, title, subtitle }: AuthLayoutProps) { 11 11 return ( 12 - <div className="min-h-screen bg-gradient-to-b from-zinc-100 to-white dark:from-zinc-900 dark:to-zinc-950 flex flex-col items-center justify-center p-4"> 12 + <div className="min-h-screen w-full bg-gradient-to-b from-zinc-100 to-white dark:from-zinc-900 dark:to-zinc-950 flex flex-col items-center justify-center p-4"> 13 13 <motion.div 14 14 initial={{ opacity: 0, y: 20 }} 15 15 animate={{ opacity: 1, y: 0 }}
+4 -9
apps/web/src/components/auth/sign-in-form.tsx
··· 8 8 FormMessage, 9 9 } from "@/components/ui/form"; 10 10 import { Input } from "@/components/ui/input"; 11 - import signIn from "@/fetchers/user/sign-in"; 11 + import useSignIn from "@/hooks/mutations/use-sign-in"; 12 12 import { zodResolver } from "@hookform/resolvers/zod"; 13 - import { useMutation } from "@tanstack/react-query"; 14 13 import { useRouter } from "@tanstack/react-router"; 15 14 import { AlertCircle, Eye, EyeOff } from "lucide-react"; 16 15 import { useState } from "react"; ··· 38 37 password: "", 39 38 }, 40 39 }); 41 - const { error, isError, mutateAsync, isPending } = useMutation({ 42 - mutationFn: () => 43 - signIn({ 44 - email: form.getValues().email, 45 - password: form.getValues().password, 46 - }), 40 + const { error, isError, mutateAsync, isPending } = useSignIn({ 41 + email: form.getValues("email"), 42 + password: form.getValues("password"), 47 43 }); 48 44 49 45 const onSubmit = async () => { 50 46 await mutateAsync(); 51 - 52 47 history.push("/dashboard"); 53 48 }; 54 49
+5 -9
apps/web/src/components/auth/sign-up-form.tsx
··· 8 8 FormMessage, 9 9 } from "@/components/ui/form"; 10 10 import { Input } from "@/components/ui/input"; 11 - import signUp from "@/fetchers/user/sign-up"; 11 + import useSignUp from "@/hooks/mutations/use-sign-up"; 12 12 import { zodResolver } from "@hookform/resolvers/zod"; 13 - import { useMutation } from "@tanstack/react-query"; 14 13 import { useRouter } from "@tanstack/react-router"; 15 14 import { AlertCircle, Eye, EyeOff } from "lucide-react"; 16 15 import { useState } from "react"; ··· 41 40 name: "", 42 41 }, 43 42 }); 44 - const { isError, error, mutateAsync } = useMutation({ 45 - mutationFn: () => 46 - signUp({ 47 - email: form.getValues().email, 48 - password: form.getValues().password, 49 - name: form.getValues().name, 50 - }), 43 + const { isError, error, mutateAsync } = useSignUp({ 44 + email: form.getValues("email"), 45 + password: form.getValues("password"), 46 + name: form.getValues("name"), 51 47 }); 52 48 53 49 const onSubmit = async () => {
+11 -11
apps/web/src/components/common/sidebar/index.tsx
··· 1 + import useAuth from "@/components/providers/auth-provider/hooks/use-auth"; 1 2 import { cn } from "@/lib/utils"; 3 + import { motion } from "framer-motion"; 2 4 import { X } from "lucide-react"; 3 5 import { useState } from "react"; 4 6 import { Logo } from "../logo"; ··· 8 10 9 11 export function Sidebar() { 10 12 const [isOpen, setIsOpen] = useState(false); 11 - // const { workspaces, currentWorkspace, setCurrentWorkspace } = useKaneoStore(); 12 - const currentUser = { 13 - name: "John Doe", 14 - email: "john@example.com", 15 - avatar: 16 - "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face", 17 - }; 13 + const { user } = useAuth(); 18 14 19 15 return ( 20 - <> 16 + <motion.div 17 + initial={{ opacity: 0, x: -20 }} 18 + animate={{ opacity: 1, x: 0 }} 19 + className="w-full" 20 + > 21 21 <MobileHeader onMenuClick={() => setIsOpen(true)} /> 22 22 23 23 <div ··· 47 47 workspaces={[]} 48 48 currentWorkspace={"Andrej's workspace"} 49 49 setCurrentWorkspace={() => {}} 50 - currentUser={currentUser} 50 + currentUser={user} 51 51 /> 52 52 </div> 53 53 </div> ··· 58 58 workspaces={[]} 59 59 currentWorkspace={"Andrej's workspace"} 60 60 setCurrentWorkspace={() => {}} 61 - currentUser={currentUser} 61 + currentUser={user} 62 62 /> 63 63 </div> 64 - </> 64 + </motion.div> 65 65 ); 66 66 }
+7 -16
apps/web/src/components/common/sidebar/sidebar-content.tsx
··· 1 - import { Folder, LogOut, Plus, Settings, Users } from "lucide-react"; 2 - import { Avatar, AvatarFallback, AvatarImage } from "../../ui/avatar"; 1 + import type { User } from "@/components/providers/auth-provider"; 2 + import { Folder, Plus, Settings, Users } from "lucide-react"; 3 + import { Avatar, AvatarFallback } from "../../ui/avatar"; 4 + import SignOutButton from "./sign-out-button"; 3 5 4 6 interface SidebarContentProps { 5 7 // biome-ignore lint/suspicious/noExplicitAny: Still WIP 6 8 workspaces: any[]; 7 9 currentWorkspace: string | null; 8 10 setCurrentWorkspace: (id: string) => void; 9 - currentUser: { 10 - name: string; 11 - email: string; 12 - avatar: string; 13 - }; 11 + currentUser: User; 14 12 } 15 13 16 14 export function SidebarContent({ ··· 77 75 78 76 <div className="p-4 border-t border-zinc-200 dark:border-zinc-800"> 79 77 <div className="flex items-center gap-3 mb-3"> 80 - <Avatar> 81 - <AvatarImage src={currentUser.avatar} alt={currentUser.name} /> 78 + <Avatar className="text-zinc-900 dark:text-zinc-100"> 82 79 <AvatarFallback>{currentUser.name.charAt(0)}</AvatarFallback> 83 80 </Avatar> 84 81 <div className="flex-1 min-w-0"> ··· 98 95 <Settings className="w-3 h-3 mr-1" /> 99 96 Settings 100 97 </button> 101 - <button 102 - type="button" 103 - className="flex-1 px-2 py-1.5 text-xs text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800 rounded-lg flex items-center justify-center transition-colors" 104 - > 105 - <LogOut className="w-3 h-3 mr-1" /> 106 - Sign out 107 - </button> 98 + <SignOutButton /> 108 99 </div> 109 100 </div> 110 101 </>
+30
apps/web/src/components/common/sidebar/sign-out-button.tsx
··· 1 + import useAuth from "@/components/providers/auth-provider/hooks/use-auth"; 2 + import useSignOut from "@/hooks/mutations/use-sign-out"; 3 + import { useRouter } from "@tanstack/react-router"; 4 + import { LogOut } from "lucide-react"; 5 + 6 + function SignOutButton() { 7 + const { setUser } = useAuth(); 8 + const { mutateAsync, isPending } = useSignOut(); 9 + const { history } = useRouter(); 10 + 11 + const handleSignOut = async () => { 12 + await mutateAsync(); 13 + setUser(null); 14 + history.push("/auth/sign-in"); 15 + }; 16 + 17 + return ( 18 + <button 19 + type="button" 20 + disabled={isPending} 21 + onClick={handleSignOut} 22 + className="flex-1 px-2 py-1.5 text-xs text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800 rounded-lg flex items-center justify-center transition-colors" 23 + > 24 + <LogOut className="w-3 h-3 mr-1" /> 25 + Sign out 26 + </button> 27 + ); 28 + } 29 + 30 + export default SignOutButton;
+14
apps/web/src/components/providers/auth-provider/hooks/use-auth.ts
··· 1 + import { useContext } from "react"; 2 + import { AuthContext } from ".."; 3 + 4 + const useAuth = () => { 5 + const context = useContext(AuthContext); 6 + 7 + if (!context) { 8 + throw new Error("useAuth must be used within an AuthProvider"); 9 + } 10 + 11 + return context; 12 + }; 13 + 14 + export default useAuth;
+58
apps/web/src/components/providers/auth-provider/index.tsx
··· 1 + import useGetMe from "@/hooks/queries/use-get-me"; 2 + import type { User } from "@/types/user"; 3 + import { LayoutGrid } from "lucide-react"; 4 + import { 5 + type PropsWithChildren, 6 + createContext, 7 + useEffect, 8 + useState, 9 + } from "react"; 10 + 11 + export const AuthContext = createContext<{ 12 + user: User | null; 13 + setUser: (user: User | null) => void; 14 + }>({ 15 + user: null, 16 + setUser: () => {}, 17 + }); 18 + 19 + function AuthProvider({ children }: PropsWithChildren) { 20 + const [user, setUser] = useState<{ 21 + name: string; 22 + id: string; 23 + password: string; 24 + email: string; 25 + createdAt: Date; 26 + } | null>(null); 27 + 28 + const { data, isLoading } = useGetMe(); 29 + 30 + useEffect(() => { 31 + if (data) { 32 + setUser(data.data); 33 + } 34 + }, [data]); 35 + 36 + if (isLoading) { 37 + return ( 38 + <div className="flex w-full items-center justify-center h-screen flex-col md:flex-row bg-zinc-50 dark:bg-zinc-950"> 39 + <div className="p-1.5 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-lg shadow-sm animate-spin"> 40 + <LayoutGrid className="w-5 h-5 text-white" /> 41 + </div> 42 + </div> 43 + ); 44 + } 45 + 46 + return ( 47 + <AuthContext.Provider 48 + value={{ 49 + user, 50 + setUser, 51 + }} 52 + > 53 + {children} 54 + </AuthContext.Provider> 55 + ); 56 + } 57 + 58 + export default AuthProvider;
+1 -1
apps/web/src/components/providers/theme-provider.tsx apps/web/src/components/providers/theme-provider/index.tsx
··· 1 - import useTheme from "@/hooks/theme/use-theme"; 2 1 import { useEffect } from "react"; 2 + import useTheme from "./hooks/use-theme"; 3 3 4 4 export function ThemeProvider({ children }: { children: React.ReactNode }) { 5 5 const { theme } = useTheme();
+1 -1
apps/web/src/components/theme/theme-toggle.tsx
··· 1 - import useTheme from "@/hooks/theme/use-theme"; 2 1 import { Moon, Sun } from "lucide-react"; 2 + import useTheme from "../providers/theme-provider/hooks/use-theme"; 3 3 4 4 export function ThemeToggle() { 5 5 const { theme, toggleTheme } = useTheme();
+49
apps/web/src/components/ui/spinner.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + import { type VariantProps, cva } from "class-variance-authority"; 3 + import { Loader2 } from "lucide-react"; 4 + 5 + const spinnerVariants = cva("flex-col items-center justify-center", { 6 + variants: { 7 + show: { 8 + true: "flex", 9 + false: "hidden", 10 + }, 11 + }, 12 + defaultVariants: { 13 + show: true, 14 + }, 15 + }); 16 + 17 + const loaderVariants = cva("animate-spin text-primary", { 18 + variants: { 19 + size: { 20 + small: "size-6", 21 + medium: "size-8", 22 + large: "size-12", 23 + }, 24 + }, 25 + defaultVariants: { 26 + size: "medium", 27 + }, 28 + }); 29 + 30 + interface SpinnerContentProps 31 + extends VariantProps<typeof spinnerVariants>, 32 + VariantProps<typeof loaderVariants> { 33 + className?: string; 34 + children?: React.ReactNode; 35 + } 36 + 37 + export function Spinner({ 38 + size, 39 + show, 40 + children, 41 + className, 42 + }: SpinnerContentProps) { 43 + return ( 44 + <span className={spinnerVariants({ show })}> 45 + <Loader2 className={cn(loaderVariants({ size }), className)} /> 46 + {children} 47 + </span> 48 + ); 49 + }
+9
apps/web/src/fetchers/user/me.ts
··· 1 + import { api } from "@kaneo/libs"; 2 + 3 + const me = async () => { 4 + const response = await api.me.get(); 5 + 6 + return response; 7 + }; 8 + 9 + export default me;
+9
apps/web/src/fetchers/user/sign-out.ts
··· 1 + import { api } from "@kaneo/libs"; 2 + 3 + const signOut = async () => { 4 + const response = await api.user["sign-out"].post(); 5 + 6 + return response; 7 + }; 8 + 9 + export default signOut;
+15
apps/web/src/hooks/mutations/use-sign-in.ts
··· 1 + import type { SignInFormValues } from "@/components/auth/sign-in-form"; 2 + import signIn from "@/fetchers/user/sign-in"; 3 + import { useMutation } from "@tanstack/react-query"; 4 + 5 + function useSignIn({ email, password }: SignInFormValues) { 6 + return useMutation({ 7 + mutationFn: () => 8 + signIn({ 9 + email, 10 + password, 11 + }), 12 + }); 13 + } 14 + 15 + export default useSignIn;
+10
apps/web/src/hooks/mutations/use-sign-out.ts
··· 1 + import signOut from "@/fetchers/user/sign-out"; 2 + import { useMutation } from "@tanstack/react-query"; 3 + 4 + function useSignOut() { 5 + return useMutation({ 6 + mutationFn: () => signOut(), 7 + }); 8 + } 9 + 10 + export default useSignOut;
+16
apps/web/src/hooks/mutations/use-sign-up.ts
··· 1 + import type { SignUpFormValues } from "@/components/auth/sign-up-form"; 2 + import signUp from "@/fetchers/user/sign-up"; 3 + import { useMutation } from "@tanstack/react-query"; 4 + 5 + function useSignUp({ email, password, name }: SignUpFormValues) { 6 + return useMutation({ 7 + mutationFn: () => 8 + signUp({ 9 + email, 10 + password, 11 + name, 12 + }), 13 + }); 14 + } 15 + 16 + export default useSignUp;
+12
apps/web/src/hooks/queries/use-get-me.ts
··· 1 + import me from "@/fetchers/user/me"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + 4 + function useGetMe() { 5 + return useQuery({ 6 + queryKey: ["me"], 7 + queryFn: () => me(), 8 + retry: 0, 9 + }); 10 + } 11 + 12 + export default useGetMe;
apps/web/src/hooks/theme/use-theme.tsx apps/web/src/components/providers/theme-provider/hooks/use-theme.tsx
+29 -3
apps/web/src/main.tsx
··· 1 1 import queryClient from "@/query-client"; 2 2 import { QueryClientProvider } from "@tanstack/react-query"; 3 - import { RouterProvider } from "@tanstack/react-router"; 3 + import { RouterProvider, createRouter } from "@tanstack/react-router"; 4 4 import { StrictMode } from "react"; 5 5 import { createRoot } from "react-dom/client"; 6 6 import "@/index.css"; 7 - import router from "@/routes"; 7 + import AuthProvider from "./components/providers/auth-provider"; 8 + import useAuth from "./components/providers/auth-provider/hooks/use-auth"; 8 9 import { ThemeProvider } from "./components/providers/theme-provider"; 10 + import { routeTree } from "./routeTree.gen"; 11 + 12 + const router = createRouter({ 13 + routeTree, 14 + defaultPreload: "intent", 15 + defaultPreloadStaleTime: 0, 16 + context: { 17 + user: null, 18 + queryClient, 19 + }, 20 + }); 21 + 22 + declare module "@tanstack/react-router" { 23 + interface Register { 24 + router: typeof router; 25 + } 26 + } 27 + 28 + function App() { 29 + const { user } = useAuth(); 30 + 31 + return <RouterProvider router={router} context={{ user }} />; 32 + } 9 33 10 34 const rootElement = document.getElementById("root") as HTMLElement; 11 35 if (!rootElement.innerHTML) { ··· 14 38 <StrictMode> 15 39 <QueryClientProvider client={queryClient}> 16 40 <ThemeProvider> 17 - <RouterProvider router={router} /> 41 + <AuthProvider> 42 + <App /> 43 + </AuthProvider> 18 44 </ThemeProvider> 19 45 </QueryClientProvider> 20 46 </StrictMode>,
+2 -5
apps/web/src/pages/auth/sign-in.tsx apps/web/src/routes/auth/sign-in.tsx
··· 1 - import { rootRoute } from "@/routes"; 2 - import { createRoute } from "@tanstack/react-router"; 1 + import { createFileRoute } from "@tanstack/react-router"; 3 2 import { AuthLayout } from "../../components/auth/layout"; 4 3 import { SignInForm } from "../../components/auth/sign-in-form"; 5 4 import { AuthToggle } from "../../components/auth/toggle"; 6 5 7 - export const signInRoute = createRoute({ 8 - getParentRoute: () => rootRoute, 9 - path: "/auth/sign-in", 6 + export const Route = createFileRoute("/auth/sign-in")({ 10 7 component: SignIn, 11 8 }); 12 9
+2 -5
apps/web/src/pages/auth/sign-up.tsx apps/web/src/routes/auth/sign-up.tsx
··· 1 1 import { AuthLayout } from "@/components/auth/layout"; 2 2 import { SignUpForm } from "@/components/auth/sign-up-form"; 3 3 import { AuthToggle } from "@/components/auth/toggle"; 4 - import { rootRoute } from "@/routes"; 5 - import { createRoute } from "@tanstack/react-router"; 4 + import { createFileRoute } from "@tanstack/react-router"; 6 5 7 - export const signUpRoute = createRoute({ 8 - getParentRoute: () => rootRoute, 9 - path: "/auth/sign-up", 6 + export const Route = createFileRoute("/auth/sign-up")({ 10 7 component: SignUp, 11 8 }); 12 9
-18
apps/web/src/pages/dashboard/index.tsx
··· 1 - import { Sidebar } from "@/components/common/sidebar"; 2 - import { createRoute } from "@tanstack/react-router"; 3 - import { indexRoute } from ".."; 4 - 5 - export const dashboardIndexRoute = createRoute({ 6 - getParentRoute: () => indexRoute, 7 - path: "/dashboard", 8 - component: DashboardIndexRouteComponent, 9 - }); 10 - 11 - function DashboardIndexRouteComponent() { 12 - return ( 13 - <> 14 - <Sidebar /> 15 - <main className="flex-1 overflow-hidden p-6">Welcome to Kaneo!</main> 16 - </> 17 - ); 18 - }
-32
apps/web/src/pages/index.tsx
··· 1 - import queryClient from "@/query-client"; 2 - import { rootRoute } from "@/routes"; 3 - import { api } from "@kaneo/libs"; 4 - import { Outlet, createRoute, redirect } from "@tanstack/react-router"; 5 - 6 - export const indexRoute = createRoute({ 7 - getParentRoute: () => rootRoute, 8 - path: "/", 9 - component: IndexRouteComponent, 10 - beforeLoad: async () => { 11 - const { data: isAuthenticated } = await queryClient.ensureQueryData({ 12 - queryKey: ["me"], 13 - queryFn: () => api.me.get(), 14 - staleTime: 15 * 60 * 1000, // 15 mins 15 - revalidateIfStale: true, 16 - }); 17 - 18 - if (!isAuthenticated) { 19 - throw redirect({ 20 - to: "/auth/sign-in", 21 - }); 22 - } 23 - }, 24 - }); 25 - 26 - function IndexRouteComponent() { 27 - return ( 28 - <div className="flex h-screen flex-col md:flex-row bg-zinc-50 dark:bg-zinc-950"> 29 - <Outlet /> 30 - </div> 31 - ); 32 - }
+74 -5
apps/web/src/routeTree.gen.ts
··· 12 12 13 13 import { Route as rootRoute } from './routes/__root' 14 14 import { Route as IndexImport } from './routes/index' 15 + import { Route as DashboardIndexImport } from './routes/dashboard/index' 16 + import { Route as AuthSignUpImport } from './routes/auth/sign-up' 17 + import { Route as AuthSignInImport } from './routes/auth/sign-in' 15 18 16 19 // Create/Update Routes 17 20 ··· 21 24 getParentRoute: () => rootRoute, 22 25 } as any) 23 26 27 + const DashboardIndexRoute = DashboardIndexImport.update({ 28 + id: '/dashboard/', 29 + path: '/dashboard/', 30 + getParentRoute: () => rootRoute, 31 + } as any) 32 + 33 + const AuthSignUpRoute = AuthSignUpImport.update({ 34 + id: '/auth/sign-up', 35 + path: '/auth/sign-up', 36 + getParentRoute: () => rootRoute, 37 + } as any) 38 + 39 + const AuthSignInRoute = AuthSignInImport.update({ 40 + id: '/auth/sign-in', 41 + path: '/auth/sign-in', 42 + getParentRoute: () => rootRoute, 43 + } as any) 44 + 24 45 // Populate the FileRoutesByPath interface 25 46 26 47 declare module '@tanstack/react-router' { ··· 32 53 preLoaderRoute: typeof IndexImport 33 54 parentRoute: typeof rootRoute 34 55 } 56 + '/auth/sign-in': { 57 + id: '/auth/sign-in' 58 + path: '/auth/sign-in' 59 + fullPath: '/auth/sign-in' 60 + preLoaderRoute: typeof AuthSignInImport 61 + parentRoute: typeof rootRoute 62 + } 63 + '/auth/sign-up': { 64 + id: '/auth/sign-up' 65 + path: '/auth/sign-up' 66 + fullPath: '/auth/sign-up' 67 + preLoaderRoute: typeof AuthSignUpImport 68 + parentRoute: typeof rootRoute 69 + } 70 + '/dashboard/': { 71 + id: '/dashboard/' 72 + path: '/dashboard' 73 + fullPath: '/dashboard' 74 + preLoaderRoute: typeof DashboardIndexImport 75 + parentRoute: typeof rootRoute 76 + } 35 77 } 36 78 } 37 79 ··· 39 81 40 82 export interface FileRoutesByFullPath { 41 83 '/': typeof IndexRoute 84 + '/auth/sign-in': typeof AuthSignInRoute 85 + '/auth/sign-up': typeof AuthSignUpRoute 86 + '/dashboard': typeof DashboardIndexRoute 42 87 } 43 88 44 89 export interface FileRoutesByTo { 45 90 '/': typeof IndexRoute 91 + '/auth/sign-in': typeof AuthSignInRoute 92 + '/auth/sign-up': typeof AuthSignUpRoute 93 + '/dashboard': typeof DashboardIndexRoute 46 94 } 47 95 48 96 export interface FileRoutesById { 49 97 __root__: typeof rootRoute 50 98 '/': typeof IndexRoute 99 + '/auth/sign-in': typeof AuthSignInRoute 100 + '/auth/sign-up': typeof AuthSignUpRoute 101 + '/dashboard/': typeof DashboardIndexRoute 51 102 } 52 103 53 104 export interface FileRouteTypes { 54 105 fileRoutesByFullPath: FileRoutesByFullPath 55 - fullPaths: '/' 106 + fullPaths: '/' | '/auth/sign-in' | '/auth/sign-up' | '/dashboard' 56 107 fileRoutesByTo: FileRoutesByTo 57 - to: '/' 58 - id: '__root__' | '/' 108 + to: '/' | '/auth/sign-in' | '/auth/sign-up' | '/dashboard' 109 + id: '__root__' | '/' | '/auth/sign-in' | '/auth/sign-up' | '/dashboard/' 59 110 fileRoutesById: FileRoutesById 60 111 } 61 112 62 113 export interface RootRouteChildren { 63 114 IndexRoute: typeof IndexRoute 115 + AuthSignInRoute: typeof AuthSignInRoute 116 + AuthSignUpRoute: typeof AuthSignUpRoute 117 + DashboardIndexRoute: typeof DashboardIndexRoute 64 118 } 65 119 66 120 const rootRouteChildren: RootRouteChildren = { 67 121 IndexRoute: IndexRoute, 122 + AuthSignInRoute: AuthSignInRoute, 123 + AuthSignUpRoute: AuthSignUpRoute, 124 + DashboardIndexRoute: DashboardIndexRoute, 68 125 } 69 126 70 127 export const routeTree = rootRoute ··· 77 134 "__root__": { 78 135 "filePath": "__root.tsx", 79 136 "children": [ 80 - "/" 137 + "/", 138 + "/auth/sign-in", 139 + "/auth/sign-up", 140 + "/dashboard/" 81 141 ] 82 142 }, 83 143 "/": { 84 - "filePath": "index.ts" 144 + "filePath": "index.tsx" 145 + }, 146 + "/auth/sign-in": { 147 + "filePath": "auth/sign-in.tsx" 148 + }, 149 + "/auth/sign-up": { 150 + "filePath": "auth/sign-up.tsx" 151 + }, 152 + "/dashboard/": { 153 + "filePath": "dashboard/index.tsx" 85 154 } 86 155 } 87 156 }
+6 -2
apps/web/src/routes/__root.tsx
··· 1 + import type { User } from "@/types/user"; 1 2 import type { QueryClient } from "@tanstack/react-query"; 2 3 import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 3 4 import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; 4 5 import { TanStackRouterDevtools } from "@tanstack/router-devtools"; 5 6 6 - export const rootRoute = createRootRouteWithContext<{ 7 + export const Route = createRootRouteWithContext<{ 7 8 queryClient: QueryClient; 9 + user: User; 8 10 }>()({ 9 11 component: RootComponent, 10 12 }); ··· 12 14 function RootComponent() { 13 15 return ( 14 16 <> 15 - <Outlet /> 17 + <div className="flex w-full h-screen flex-col md:flex-row bg-zinc-50 dark:bg-zinc-950"> 18 + <Outlet /> 19 + </div> 16 20 <ReactQueryDevtools buttonPosition="top-right" /> 17 21 <TanStackRouterDevtools position="bottom-right" /> 18 22 </>
+19
apps/web/src/routes/dashboard/index.tsx
··· 1 + import { Sidebar } from "@/components/common/sidebar"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + 4 + export const Route = createFileRoute("/dashboard/")({ 5 + component: DashboardIndexRouteComponent, 6 + loader: ({ context: { queryClient } }) => 7 + queryClient.ensureQueryData({ 8 + queryKey: ["me"], 9 + }), 10 + }); 11 + 12 + function DashboardIndexRouteComponent() { 13 + return ( 14 + <> 15 + <Sidebar /> 16 + <main className="flex-1 overflow-hidden p-6" /> 17 + </> 18 + ); 19 + }
-38
apps/web/src/routes/index.ts
··· 1 - import { indexRoute } from "@/pages"; 2 - import { signInRoute } from "@/pages/auth/sign-in"; 3 - import { signUpRoute } from "@/pages/auth/sign-up"; 4 - import { dashboardIndexRoute } from "@/pages/dashboard"; 5 - import queryClient from "@/query-client"; 6 - import type { QueryClient } from "@tanstack/react-query"; 7 - import { 8 - createRootRouteWithContext, 9 - createRouter, 10 - } from "@tanstack/react-router"; 11 - import RootComponent from "./__root"; 12 - 13 - export const rootRoute = createRootRouteWithContext<{ 14 - queryClient: QueryClient; 15 - }>()({ 16 - component: RootComponent, 17 - }); 18 - 19 - const routeTree = rootRoute.addChildren([ 20 - indexRoute.addChildren([signInRoute, signUpRoute, dashboardIndexRoute]), 21 - ]); 22 - 23 - const router = createRouter({ 24 - routeTree, 25 - defaultPreload: "intent", 26 - defaultPreloadStaleTime: 0, 27 - context: { 28 - queryClient, 29 - }, 30 - }); 31 - 32 - declare module "@tanstack/react-router" { 33 - interface Register { 34 - router: typeof router; 35 - } 36 - } 37 - 38 - export default router;
+23
apps/web/src/routes/index.tsx
··· 1 + import type { User } from "@/types/user"; 2 + import { redirect } from "@tanstack/react-router"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 + 5 + export const Route = createFileRoute("/")({ 6 + async beforeLoad({ context: { queryClient } }) { 7 + const { data: user } = await queryClient.ensureQueryData<{ 8 + data: User; 9 + }>({ 10 + queryKey: ["me"], 11 + }); 12 + 13 + if (!user) { 14 + throw redirect({ 15 + to: "/auth/sign-in", 16 + }); 17 + } 18 + 19 + throw redirect({ 20 + to: "/dashboard", 21 + }); 22 + }, 23 + });
+5
apps/web/src/types/api-response.ts
··· 1 + type ApiResponse<T extends (...args: never[]) => Promise<unknown>> = Awaited< 2 + ReturnType<T> 3 + >["data"]; 4 + 5 + export default ApiResponse;
+4
apps/web/src/types/user.ts
··· 1 + import type { api } from "@kaneo/libs"; 2 + import type ApiResponse from "./api-response"; 3 + 4 + export type User = ApiResponse<typeof api.me.get>;
+33 -2
pnpm-lock.yaml
··· 54 54 '@kaneo/typescript-config': 55 55 specifier: workspace:* 56 56 version: link:../../packages/typescript-config 57 + '@oslojs/crypto': 58 + specifier: ^1.0.1 59 + version: 1.0.1 60 + '@oslojs/encoding': 61 + specifier: ^1.1.0 62 + version: 1.1.0 57 63 '@paralleldrive/cuid2': 58 64 specifier: ^2.2.2 59 65 version: 2.2.2 ··· 128 134 specifier: ^1.0.7 129 135 version: 1.0.7(tailwindcss@3.4.17) 130 136 zod: 131 - specifier: link:@hookform/resolvers/zod 132 - version: link:@hookform/resolvers/zod 137 + specifier: 3.24.1 138 + version: 3.24.1 133 139 zustand: 134 140 specifier: ^5.0.2 135 141 version: 5.0.2(@types/react@18.3.18)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) ··· 1049 1055 '@nodelib/fs.walk@1.2.8': 1050 1056 resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} 1051 1057 engines: {node: '>= 8'} 1058 + 1059 + '@oslojs/asn1@1.0.0': 1060 + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} 1061 + 1062 + '@oslojs/binary@1.0.0': 1063 + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} 1064 + 1065 + '@oslojs/crypto@1.0.1': 1066 + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} 1067 + 1068 + '@oslojs/encoding@1.1.0': 1069 + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} 1052 1070 1053 1071 '@paralleldrive/cuid2@2.2.2': 1054 1072 resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} ··· 3294 3312 dependencies: 3295 3313 '@nodelib/fs.scandir': 2.1.5 3296 3314 fastq: 1.18.0 3315 + 3316 + '@oslojs/asn1@1.0.0': 3317 + dependencies: 3318 + '@oslojs/binary': 1.0.0 3319 + 3320 + '@oslojs/binary@1.0.0': {} 3321 + 3322 + '@oslojs/crypto@1.0.1': 3323 + dependencies: 3324 + '@oslojs/asn1': 1.0.0 3325 + '@oslojs/binary': 1.0.0 3326 + 3327 + '@oslojs/encoding@1.1.0': {} 3297 3328 3298 3329 '@paralleldrive/cuid2@2.2.2': 3299 3330 dependencies: