From 91126a3456524d840680047c0c108577c52ce4dc Mon Sep 17 00:00:00 2001 From: Derock Date: Tue, 7 Nov 2023 11:20:29 -0500 Subject: [PATCH] feat: show sessions --- .env.example | 1 + package.json | 1 + pnpm-lock.yaml | 10 ++++++++++ src/app/settings/sessions/Sessions.tsx | 20 +++++++++++++++++--- src/app/settings/sessions/loading.tsx | 26 ++++++++++++++++++++++++++ src/app/settings/sessions/page.tsx | 2 ++ src/components/RelativeDate.tsx | 6 ++++++ src/components/ui/skeleton.tsx | 15 +++++++++++++++ src/env.mjs | 2 ++ src/server/api/trpc.ts | 5 ++++- src/server/auth/Session.ts | 20 ++++++++++++++++++-- 11 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/app/settings/sessions/loading.tsx create mode 100644 src/components/RelativeDate.tsx create mode 100644 src/components/ui/skeleton.tsx diff --git a/.env.example b/.env.example index 168cf5b..25f7089 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,4 @@ # Prisma # https://www.prisma.io/docs/reference/database-reference/connection-urls#env DATABASE_URL="file:./db.sqlite" +SESSION_SECRET="verysecuresalt \ No newline at end of file diff --git a/package.json b/package.json index eded653..1187855 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "bunyan-format": "^0.2.1", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "date-fns": "^2.30.0", "drizzle-orm": "^0.28.6", "next": "^14.0.1", "next-themes": "^0.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a76654d..505262f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ dependencies: clsx: specifier: ^2.0.0 version: 2.0.0 + date-fns: + specifier: ^2.30.0 + version: 2.30.0 drizzle-orm: specifier: ^0.28.6 version: 0.28.6(@types/better-sqlite3@7.6.6)(better-sqlite3@9.0.0)(pg@8.11.3) @@ -2279,6 +2282,13 @@ packages: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true + /date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + dependencies: + '@babel/runtime': 7.23.2 + dev: false + /debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: diff --git a/src/app/settings/sessions/Sessions.tsx b/src/app/settings/sessions/Sessions.tsx index 8c43ec8..301ee11 100644 --- a/src/app/settings/sessions/Sessions.tsx +++ b/src/app/settings/sessions/Sessions.tsx @@ -1,5 +1,6 @@ import { Card } from "~/components/ui/card"; import UAParser from "ua-parser-js"; +import { RelativeDate } from "~/components/RelativeDate"; type SessionData = { lastUA: string | null; @@ -10,13 +11,26 @@ type SessionData = { }; export default function Session(props: { session: SessionData }) { - const ua = new UAParser(props.session.lastUA ?? ""); + const ua = new UAParser(props.session.lastUA ?? "").getResult(); return ( -

{ua.getBrowser().name}

+

+ {ua.os.name ?? ua.ua} + {ua.browser.name + ? ua.browser.version + ? ` ${ua.browser.name} v${ua.browser.version}` + : ` ${ua.browser.name}` + : null} +

- {ua.getOS().name} {ua.getOS().version} + {props.session.lastIP ?? IP Unknown} + {" • "} + {props.session.lastAccessed ? ( + + ) : ( + Never accessed + )}

); diff --git a/src/app/settings/sessions/loading.tsx b/src/app/settings/sessions/loading.tsx new file mode 100644 index 0000000..1f7ad80 --- /dev/null +++ b/src/app/settings/sessions/loading.tsx @@ -0,0 +1,26 @@ +import { Card } from "~/components/ui/card"; +import { Skeleton } from "~/components/ui/skeleton"; + +function LoadingCard() { + return ( + +
+ +
+ + +
+
+
+ ); +} + +export default function SessionsLoading() { + return ( +
+ + + +
+ ); +} diff --git a/src/app/settings/sessions/page.tsx b/src/app/settings/sessions/page.tsx index 12369ec..34b6a0f 100644 --- a/src/app/settings/sessions/page.tsx +++ b/src/app/settings/sessions/page.tsx @@ -3,6 +3,8 @@ import { api } from "~/trpc/server"; import Session from "./Sessions"; export default async function SessionsPage() { + // fake loading + // await new Promise((resolve) => setTimeout(resolve, 5000)); const sessions = await api.auth.sessions.list.query(); return ( diff --git a/src/components/RelativeDate.tsx b/src/components/RelativeDate.tsx new file mode 100644 index 0000000..450206d --- /dev/null +++ b/src/components/RelativeDate.tsx @@ -0,0 +1,6 @@ +"use client"; +import formatRelative from "date-fns/formatRelative"; + +export function RelativeDate({ date }: { date: Date }) { + return formatRelative(date, new Date()); +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..b7ef19e --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "~/utils/utils"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/src/env.mjs b/src/env.mjs index 21e53ea..cbd80e6 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -14,6 +14,7 @@ export const env = createEnv({ .default("development"), SQLITE_UUIDV7_EXT_PATH: z.string().optional(), + SESSION_SECRET: z.string(), }, /** @@ -33,6 +34,7 @@ export const env = createEnv({ DATABASE_PATH: process.env.DATABASE_PATH, NODE_ENV: process.env.NODE_ENV, SQLITE_UUIDV7_EXT_PATH: process.env.SQLITE_UUIDV7_EXT_PATH, + SESSION_SECRET: process.env.SESSION_SECRET, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 52e6b7a..b5bc94b 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -7,7 +7,7 @@ * need to use are documented accordingly near the end. */ import { TRPCError, initTRPC } from "@trpc/server"; -import { NextResponse, type NextRequest } from "next/server"; +import { type NextRequest } from "next/server"; import superjson from "superjson"; import { ZodError } from "zod"; @@ -57,6 +57,9 @@ export const createTRPCContext = async (opts: { req: NextRequest; resHeaders: Headers; }) => { + // disable caching + opts.resHeaders.set("Cache-Control", "no-store"); + // resolve session data const sessionToken = opts.req.cookies.get("sessionToken")?.value; diff --git a/src/server/auth/Session.ts b/src/server/auth/Session.ts index 2c4b8a9..f721063 100644 --- a/src/server/auth/Session.ts +++ b/src/server/auth/Session.ts @@ -4,7 +4,8 @@ import { users, sessions } from "../db/schema"; import { randomBytes } from "crypto"; import assert from "assert"; import { NextRequest, userAgent } from "next/server"; -import { hash } from "argon2"; +import { hash as argon2Hash } from "argon2"; +import { env } from "~/env.mjs"; export type SessionUpdateData = Partial<{ ua: string; @@ -18,12 +19,24 @@ export class Session { */ static readonly EXPIRE_TIME = 1000 * 60 * 60 * 24 * 30; + /** + * Hash function + */ + static async hash(token: string) { + return argon2Hash(token, { + salt: Buffer.from(env.SESSION_SECRET), + }); + } + /** * Fetch a session from a session token. * @param token The session cookie * @returns */ static async fetchFromToken(token: string) { + // hash token + token = await this.hash(token); + const [sessionData] = await db .select() .from(sessions) @@ -52,6 +65,9 @@ export class Session { ? Session.getContextFromRequest(context) : context; + // hash token + token = await this.hash(token); + const [sessionData] = await db .update(sessions) .set({ @@ -91,7 +107,7 @@ export class Session { .values({ lastUA: parsedContext.ua, lastIP: parsedContext.ip, - token: await hash(token), + token: await this.hash(token), userId, }) .returning();