feat: show sessions

This commit is contained in:
Derock 2023-11-07 11:20:29 -05:00
parent b6c91e8bd0
commit 91126a3456
No known key found for this signature in database
11 changed files with 102 additions and 6 deletions

View file

@ -12,3 +12,4 @@
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DATABASE_URL="file:./db.sqlite"
SESSION_SECRET="verysecuresalt

View file

@ -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",

View file

@ -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:

View file

@ -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 (
<Card className="p-6">
<p className="text-lg font-bold">{ua.getBrowser().name}</p>
<p className="text-lg">
<span className="font-black">{ua.os.name ?? ua.ua}</span>
{ua.browser.name
? ua.browser.version
? ` ${ua.browser.name} v${ua.browser.version}`
: ` ${ua.browser.name}`
: null}
</p>
<p className="text-sm text-muted-foreground">
{ua.getOS().name} {ua.getOS().version}
{props.session.lastIP ?? <span className="italic">IP Unknown</span>}
{" • "}
{props.session.lastAccessed ? (
<RelativeDate date={props.session.lastAccessed} />
) : (
<span className="italic">Never accessed</span>
)}
</p>
</Card>
);

View file

@ -0,0 +1,26 @@
import { Card } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
function LoadingCard() {
return (
<Card className="animate-pulse p-6">
<div className="flex items-center space-x-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />
</div>
</div>
</Card>
);
}
export default function SessionsLoading() {
return (
<div className="flex flex-col gap-4">
<LoadingCard />
<LoadingCard />
<LoadingCard />
</div>
);
}

View file

@ -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 (

View file

@ -0,0 +1,6 @@
"use client";
import formatRelative from "date-fns/formatRelative";
export function RelativeDate({ date }: { date: Date }) {
return formatRelative(date, new Date());
}

View file

@ -0,0 +1,15 @@
import { cn } from "~/utils/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton };

View file

@ -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

View file

@ -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;

View file

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