feat: add sign in feature

This commit is contained in:
2026-08-27 20:29:23 +07:00
parent 0ff553bf27
commit 3a497fd539
24 changed files with 1109 additions and 13 deletions
+32 -5
View File
@@ -1,4 +1,7 @@
import { useHealthQuery } from "./queries/health";
import { navigate, usePathname } from "./lib/router";
import { LoginPage } from "./pages/LoginPage";
import { useLogoutMutation, useSessionQuery } from "./queries/auth";
type IconProps = {
className?: string;
@@ -54,8 +57,10 @@ function formatCheckedAt(timestamp: number) {
}).format(new Date(timestamp));
}
function App() {
export function LandingPage() {
const { data: health, error, isError, isFetching, isPending, refetch } = useHealthQuery();
const sessionQuery = useSessionQuery();
const logoutMutation = useLogoutMutation();
const hasHealth = health !== undefined;
const isHealthy = hasHealth && health.ok && health.db?.ok === 1;
const statusLabel = isPending ? "Checking" : isHealthy ? "Operational" : "Degraded";
@@ -76,10 +81,27 @@ function App() {
<a href="#status">Status</a>
</div>
<a className="nav-cta" href="#status">
View live status
<ArrowIcon />
</a>
<div className="nav-actions">
{sessionQuery.data?.user ? (
<button
className="nav-auth"
type="button"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
{logoutMutation.isPending ? "Signing out…" : "Sign out"}
</button>
) : (
<a className="nav-auth" href="/login" onClick={(event) => {
event.preventDefault();
navigate("/login");
}}>Sign in</a>
)}
<a className="nav-cta" href="#status">
View live status
<ArrowIcon />
</a>
</div>
</nav>
</header>
@@ -195,4 +217,9 @@ function App() {
);
}
function App() {
const pathname = usePathname();
return pathname === "/login" ? <LoginPage /> : <LandingPage />;
}
export default App;
+30
View File
@@ -0,0 +1,30 @@
import { getJson, postJson } from "./http";
export type AuthUser = {
id: string;
email: string;
};
export type SessionResponse = {
user: AuthUser | null;
};
export type LoginInput = {
email: string;
password: string;
};
export function getSession(signal?: AbortSignal) {
return getJson<SessionResponse>("/api/auth/me", {
signal,
credentials: "same-origin",
});
}
export function login(input: LoginInput) {
return postJson<SessionResponse>("/api/auth/login", input);
}
export function logout() {
return postJson<{ ok: true }>("/api/auth/logout");
}
+31 -4
View File
@@ -8,6 +8,19 @@ export class ApiError extends Error {
}
}
async function getErrorMessage(response: Response) {
try {
const body = await response.json<{ message?: unknown }>();
if (typeof body.message === "string" && body.message.length > 0) {
return body.message;
}
} catch {
// Fall back to the HTTP status when the response is not JSON.
}
return `Request returned HTTP ${response.status}`;
}
export async function getJson<T>(
input: RequestInfo | URL,
init: RequestInit = {},
@@ -21,11 +34,25 @@ export async function getJson<T>(
});
if (!response.ok) {
throw new ApiError(
`Request returned HTTP ${response.status}`,
response.status,
);
throw new ApiError(await getErrorMessage(response), response.status);
}
return response.json() as Promise<T>;
}
export function postJson<T>(
input: RequestInfo | URL,
body?: unknown,
init: RequestInit = {},
) {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
return getJson<T>(input, {
...init,
method: "POST",
headers,
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
}
+21
View File
@@ -0,0 +1,21 @@
import { useSyncExternalStore } from "react";
const subscribe = (listener: () => void) => {
window.addEventListener("popstate", listener);
return () => window.removeEventListener("popstate", listener);
};
const getPathname = () => window.location.pathname;
const getServerPathname = () => "/";
export function usePathname() {
return useSyncExternalStore(subscribe, getPathname, getServerPathname);
}
export function navigate(path: string, options: { replace?: boolean } = {}) {
if (path === window.location.pathname) return;
const method = options.replace ? "replaceState" : "pushState";
window.history[method](null, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
window.scrollTo({ top: 0 });
}
+1
View File
@@ -0,0 +1 @@
export { LandingPage } from "../App";
+84
View File
@@ -0,0 +1,84 @@
import { type FormEvent, useEffect, useState } from "react";
import { navigate } from "../lib/router";
import { useLoginMutation, useSessionQuery } from "../queries/auth";
export function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const sessionQuery = useSessionQuery();
const loginMutation = useLoginMutation();
useEffect(() => {
if (sessionQuery.data?.user) navigate("/", { replace: true });
}, [sessionQuery.data?.user]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
loginMutation.mutate(
{ email, password },
{ onSuccess: () => navigate("/", { replace: true }) },
);
}
const errorMessage = loginMutation.error instanceof Error
? loginMutation.error.message
: "Unable to sign in";
return (
<main className="auth-page">
<a className="auth-brand" href="/" onClick={(event) => {
event.preventDefault();
navigate("/");
}} aria-label="Upwatch home">
<span className="auth-brand-mark" aria-hidden="true">ϟ</span>
<span>upwatch</span>
</a>
<section className="auth-card" aria-labelledby="login-title">
<div className="auth-heading">
<p>Admin access</p>
<h1 id="login-title">Sign in to Upwatch</h1>
<span>Manage monitors and review incidents from one place.</span>
</div>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="auth-field">
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoFocus
/>
</div>
<div className="auth-field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
</div>
{loginMutation.isError && (
<p className="auth-error" role="alert">{errorMessage}</p>
)}
<button className="auth-submit" type="submit" disabled={loginMutation.isPending}>
{loginMutation.isPending ? "Signing in…" : "Sign in"}
</button>
</form>
</section>
<p className="auth-footnote">Protected by an encrypted, seven-day session.</p>
</main>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { queryOptions, useMutation, useQuery } from "@tanstack/react-query";
import { getSession, login, logout, type LoginInput, type SessionResponse } from "../api/auth";
import { queryClient } from "../lib/query-client";
export const authKeys = {
all: ["auth"] as const,
session: () => [...authKeys.all, "session"] as const,
};
export const sessionQueryOptions = () =>
queryOptions({
queryKey: authKeys.session(),
queryFn: ({ signal }) => getSession(signal),
staleTime: 60_000,
retry: false,
});
export function useSessionQuery() {
return useQuery(sessionQueryOptions());
}
export function useLoginMutation() {
return useMutation({
mutationFn: (input: LoginInput) => login(input),
onSuccess: (session) => {
queryClient.setQueryData(authKeys.session(), session);
},
});
}
export function useLogoutMutation() {
return useMutation({
mutationFn: logout,
onSuccess: () => {
queryClient.setQueryData<SessionResponse>(authKeys.session(), { user: null });
},
});
}
+66
View File
@@ -45,6 +45,13 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--primary-deep);
.nav-links { display: flex; align-items: center; gap: 34px; font-size: 14px; color: #4d4d4d; }
.nav-links a, .text-link { transition: color 160ms ease; }
.nav-links a:hover, .text-link:hover { color: var(--primary-deep); }
.nav-actions { display: flex; align-items: center; justify-self: end; gap: 16px; }
.nav-auth {
padding: 0; border: 0; font-size: 13px; font-weight: 500; color: #525252; background: transparent;
cursor: pointer; transition: color 160ms ease;
}
.nav-auth:hover:not(:disabled) { color: var(--primary-deep); }
.nav-auth:disabled { cursor: wait; opacity: 0.55; }
.nav-cta {
display: inline-flex; align-items: center; justify-self: end; gap: 8px; min-height: 36px; padding: 0 14px;
border: 1px solid #cfcfcf; border-radius: 6px; font-size: 13px; font-weight: 500; background: #fff;
@@ -175,6 +182,57 @@ h1 { max-width: 590px; margin: 0; font-size: clamp(48px, 4.65vw, 68px); font-wei
.site-footer > div { gap: 28px; }
.site-footer > div span { gap: 7px; }
.auth-page {
display: grid;
grid-template-rows: auto 1fr auto;
justify-items: center;
min-height: 100dvh;
padding: 32px 24px 24px;
background:
radial-gradient(circle at 50% 38%, rgb(62 207 142 / 0.08), transparent 34%),
linear-gradient(#fff, #fcfcfc);
}
.auth-page::before {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image: radial-gradient(#dcdcdc 0.65px, transparent 0.65px);
background-size: 16px 16px; mask-image: linear-gradient(to bottom, transparent, black 24%, transparent 76%); content: "";
}
.auth-brand {
position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px;
font-size: 20px; font-weight: 600; letter-spacing: -0.6px;
}
.auth-brand-mark { display: grid; place-items: center; width: 26px; height: 26px; color: var(--primary-deep); font-size: 23px; }
.auth-card {
position: relative; z-index: 1; align-self: center; width: min(100%, 420px); padding: 36px;
border: 1px solid var(--hairline); border-radius: 12px; background: rgb(255 255 255 / 0.96);
box-shadow: 0 18px 55px rgb(24 74 52 / 0.08), 0 2px 8px rgb(0 0 0 / 0.04);
animation: enter-copy 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.auth-heading p { margin: 0 0 12px; font: 500 10px/1.4 "IBM Plex Mono", monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #55816e; }
.auth-heading h1 { margin: 0; font-size: 30px; line-height: 1.16; letter-spacing: -1.2px; }
.auth-heading > span { display: block; margin-top: 12px; font-size: 14px; line-height: 1.55; color: #626262; }
.auth-form { display: grid; gap: 20px; margin-top: 30px; }
.auth-field { display: grid; gap: 8px; }
.auth-field label { font-size: 13px; font-weight: 500; color: #353535; }
.auth-field input {
width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid #cfcfcf; border-radius: 6px;
font: inherit; font-size: 14px; color: var(--ink); background: #fff;
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025); transition: border-color 160ms ease, box-shadow 160ms ease;
}
.auth-field input:hover { border-color: #a9a9a9; }
.auth-field input:focus { border-color: var(--primary-deep); outline: 0; box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14); }
.auth-error { margin: -4px 0 0; padding: 10px 12px; border: 1px solid #efcaca; border-radius: 6px; font-size: 12px; line-height: 1.45; color: #9f2f2f; background: #fff6f6; }
.auth-submit {
display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 42px; padding: 8px 16px;
border: 1px solid #35c586; border-radius: 6px; font-size: 14px; font-weight: 600; color: var(--ink); background: var(--primary);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.2); cursor: pointer;
transition: background 160ms ease, transform 160ms ease;
}
.auth-submit:hover:not(:disabled) { background: #36c487; transform: translateY(-1px); }
.auth-submit:active:not(:disabled) { background: var(--primary-deep); transform: translateY(1px); }
.auth-submit:disabled { cursor: wait; opacity: 0.62; }
.auth-footnote { position: relative; z-index: 1; margin: 0; font-size: 11px; color: #858585; }
@keyframes enter-copy { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
@keyframes enter-stage { from { opacity: 0; transform: translateY(24px) rotateY(-2deg); } to { opacity: 1; transform: translateY(0) rotateY(0); } }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -192,6 +250,8 @@ h1 { max-width: 590px; margin: 0; font-size: clamp(48px, 4.65vw, 68px); font-wei
.nav-container, .hero, .site-footer { width: min(100% - 32px, 1280px); }
.nav-container { grid-template-columns: 1fr auto; height: 62px; }
.nav-links { display: none; }
.nav-actions { gap: 10px; }
.nav-cta { display: none; }
.hero { min-height: auto; padding: 58px 0 70px; }
h1 { font-size: clamp(40px, 12vw, 56px); line-height: 1.04; letter-spacing: -2.25px; }
.hero-lead { font-size: 16px; }
@@ -208,6 +268,12 @@ h1 { max-width: 590px; margin: 0; font-size: clamp(48px, 4.65vw, 68px); font-wei
.site-footer { flex-direction: column; align-items: flex-start; gap: 12px; padding: 24px 0; }
}
@media (max-width: 520px) {
.auth-page { padding: 24px 16px; }
.auth-card { padding: 28px 22px; }
.auth-heading h1 { font-size: 27px; }
}
@media (max-width: 430px) {
.announcement { font-size: 11px; }
.hero-actions { align-items: flex-start; flex-direction: column; gap: 18px; }
+6
View File
@@ -0,0 +1,6 @@
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export const getDb = (env: Env) => drizzle(env.DB, { schema });
export type Database = ReturnType<typeof getDb>;
+37
View File
@@ -0,0 +1,37 @@
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable(
"users",
{
id: text("id").primaryKey(),
email: text("email").notNull(),
passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => [uniqueIndex("users_email_unique").on(table.email)],
);
export const sessions = sqliteTable(
"sessions",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
userAgent: text("user_agent"),
},
(table) => [index("sessions_expires_at_idx").on(table.expiresAt)],
);
export const loginAttempts = sqliteTable(
"login_attempts",
{
id: integer("id").primaryKey({ autoIncrement: true }),
ipAddress: text("ip_address").notNull(),
attemptedAt: integer("attempted_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => [index("login_attempts_ip_attempted_at_idx").on(table.ipAddress, table.attemptedAt)],
);
+10 -2
View File
@@ -1,7 +1,12 @@
import { Hono } from "hono";
import { csrf } from "hono/csrf";
import authRoutes from "./routes/auth";
import { cleanupExpiredAuthRecords } from "./scheduled/cleanup";
const app = new Hono<{ Bindings: Env }>();
app.use("/api/auth/*", csrf());
app.get("/api/health", async (context) => {
const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{
ok: number;
@@ -14,12 +19,15 @@ app.get("/api/health", async (context) => {
});
});
app.route("/", authRoutes);
export default {
fetch: app.fetch,
scheduled(controller) {
async scheduled(controller, env) {
await cleanupExpiredAuthRecords(env);
console.log(
JSON.stringify({
message: "scheduled smoke test",
message: "scheduled auth cleanup completed",
cron: controller.cron,
scheduledTime: controller.scheduledTime,
}),
+84
View File
@@ -0,0 +1,84 @@
const HASH_ALGORITHM = "SHA-256";
const HASH_BYTES = 32;
const SALT_BYTES = 16;
// Kept conservative for the Workers Free plan. Increase this after raising the
// Worker CPU limit and re-hash the admin password.
export const PBKDF2_ITERATIONS = 25_000;
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string) {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function derivePassword(plain: string, salt: ArrayBuffer, iterations: number) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(plain),
"PBKDF2",
false,
["deriveBits"],
);
return new Uint8Array(
await crypto.subtle.deriveBits(
{
name: "PBKDF2",
hash: HASH_ALGORITHM,
salt,
iterations,
},
key,
HASH_BYTES * 8,
),
);
}
export async function hashPassword(plain: string) {
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const hash = await derivePassword(plain, Uint8Array.from(salt).buffer, PBKDF2_ITERATIONS);
return `pbkdf2$sha256$${PBKDF2_ITERATIONS}$${bytesToBase64(salt)}$${bytesToBase64(hash)}`;
}
export async function verifyPassword(plain: string, stored: string) {
const [scheme, digest, iterationValue, saltValue, hashValue, ...extra] = stored.split("$");
const iterations = Number(iterationValue);
if (
scheme !== "pbkdf2" ||
digest !== "sha256" ||
extra.length > 0 ||
!Number.isSafeInteger(iterations) ||
iterations < 1 ||
iterations > 1_000_000 ||
!saltValue ||
!hashValue
) {
return false;
}
try {
const salt = base64ToBytes(saltValue);
const expected = base64ToBytes(hashValue);
if (salt.length !== SALT_BYTES || expected.length !== HASH_BYTES) return false;
const actual = await derivePassword(
plain,
Uint8Array.from(salt).buffer,
iterations,
);
const subtle = crypto.subtle as SubtleCrypto & {
timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean;
};
return subtle.timingSafeEqual(actual, expected);
} catch {
return false;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { getCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { getDb } from "../db/client";
import { getSessionUser, SESSION_COOKIE, type SessionUser } from "./session";
export type AuthVariables = {
user: SessionUser;
};
export const requireAuth = createMiddleware<{
Bindings: Env;
Variables: AuthVariables;
}>(async (context, next) => {
const token = getCookie(context, SESSION_COOKIE);
if (!token) return context.json({ message: "Authentication required" }, 401);
const user = await getSessionUser(getDb(context.env), token);
if (!user) return context.json({ message: "Authentication required" }, 401);
context.set("user", user);
await next();
});
+77
View File
@@ -0,0 +1,77 @@
import { and, eq, gt } from "drizzle-orm";
import type { CookieOptions } from "hono/utils/cookie";
import type { Database } from "../db/client";
import { sessions, users } from "../db/schema";
export const SESSION_COOKIE = "upwatch_session";
export const SESSION_DURATION_SECONDS = 7 * 24 * 60 * 60;
export type SessionUser = {
id: string;
email: string;
};
function bytesToBase64Url(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
async function sha256Hex(value: string) {
const digest = new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
);
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function sessionCookieOptions(requestUrl: string): CookieOptions {
return {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: SESSION_DURATION_SECONDS,
secure: new URL(requestUrl).protocol === "https:",
};
}
export async function createSession(
db: Database,
userId: string,
userAgent: string | null,
) {
const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
const now = new Date();
await db.insert(sessions).values({
id: await sha256Hex(token),
userId,
createdAt: now,
expiresAt: new Date(now.getTime() + SESSION_DURATION_SECONDS * 1000),
userAgent,
});
return token;
}
export async function getSessionUser(
db: Database,
token: string,
): Promise<SessionUser | null> {
const [result] = await db
.select({ id: users.id, email: users.email })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(
and(
eq(sessions.id, await sha256Hex(token)),
gt(sessions.expiresAt, new Date()),
),
)
.limit(1);
return result ?? null;
}
export async function revokeSession(db: Database, token: string) {
await db.delete(sessions).where(eq(sessions.id, await sha256Hex(token)));
}
+114
View File
@@ -0,0 +1,114 @@
import { and, count, eq, gte } from "drizzle-orm";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { getDb } from "../db/client";
import { loginAttempts, users } from "../db/schema";
import { verifyPassword } from "../lib/password";
import { requireAuth, type AuthVariables } from "../lib/require-auth";
import {
createSession,
getSessionUser,
revokeSession,
SESSION_COOKIE,
sessionCookieOptions,
} from "../lib/session";
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const MAX_FAILED_ATTEMPTS = 10;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type LoginBody = {
email?: unknown;
password?: unknown;
};
const authRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
authRoutes.post("/api/auth/login", async (context) => {
let body: LoginBody;
try {
body = await context.req.json<LoginBody>();
} catch {
return context.json({ message: "Invalid request body" }, 400);
}
if (
typeof body.email !== "string" ||
!EMAIL_PATTERN.test(body.email.trim()) ||
typeof body.password !== "string" ||
body.password.length < 8
) {
return context.json(
{ message: "Enter a valid email and a password of at least 8 characters" },
400,
);
}
const db = getDb(context.env);
const ipAddress = context.req.header("CF-Connecting-IP") ?? "unknown";
const cutoff = new Date(Date.now() - LOGIN_WINDOW_MS);
const [attemptResult] = await db
.select({ value: count() })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.ipAddress, ipAddress),
gte(loginAttempts.attemptedAt, cutoff),
),
);
if ((attemptResult?.value ?? 0) >= MAX_FAILED_ATTEMPTS) {
return context.json(
{ message: "Too many login attempts. Try again later" },
429,
);
}
const email = body.email.trim().toLowerCase();
const [user] = await db
.select({
id: users.id,
email: users.email,
passwordHash: users.passwordHash,
})
.from(users)
.where(eq(users.email, email))
.limit(1);
const passwordMatches = user
? await verifyPassword(body.password, user.passwordHash)
: false;
if (!user || !passwordMatches) {
await db.insert(loginAttempts).values({ ipAddress, attemptedAt: new Date() });
return context.json({ message: "Email or password is incorrect" }, 401);
}
await db.delete(loginAttempts).where(eq(loginAttempts.ipAddress, ipAddress));
const token = await createSession(
db,
user.id,
context.req.header("User-Agent") ?? null,
);
setCookie(context, SESSION_COOKIE, token, sessionCookieOptions(context.req.url));
return context.json({ user: { id: user.id, email: user.email } });
});
authRoutes.post("/api/auth/logout", requireAuth, async (context) => {
const token = getCookie(context, SESSION_COOKIE);
if (token) await revokeSession(getDb(context.env), token);
deleteCookie(context, SESSION_COOKIE, {
path: "/",
secure: new URL(context.req.url).protocol === "https:",
});
return context.json({ ok: true });
});
authRoutes.get("/api/auth/me", async (context) => {
const token = getCookie(context, SESSION_COOKIE);
const user = token ? await getSessionUser(getDb(context.env), token) : null;
return context.json({ user });
});
export default authRoutes;
+22
View File
@@ -0,0 +1,22 @@
import { lt } from "drizzle-orm";
import { getDb } from "../db/client";
import { loginAttempts, sessions } from "../db/schema";
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
export async function cleanupExpiredAuthRecords(env: Env) {
const db = getDb(env);
const now = new Date();
await db.batch([
db.delete(sessions).where(lt(sessions.expiresAt, now)),
db
.delete(loginAttempts)
.where(
lt(
loginAttempts.attemptedAt,
new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS),
),
),
]);
}