feat: show sessions
This commit is contained in:
parent
b6c91e8bd0
commit
91126a3456
|
@ -12,3 +12,4 @@
|
|||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DATABASE_URL="file:./db.sqlite"
|
||||
SESSION_SECRET="verysecuresalt
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
26
src/app/settings/sessions/loading.tsx
Normal file
26
src/app/settings/sessions/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
|
|
6
src/components/RelativeDate.tsx
Normal file
6
src/components/RelativeDate.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
"use client";
|
||||
import formatRelative from "date-fns/formatRelative";
|
||||
|
||||
export function RelativeDate({ date }: { date: Date }) {
|
||||
return formatRelative(date, new Date());
|
||||
}
|
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 };
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue