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();