Basic account and session system

Provide a basic account system with login and server side session store
identified by a cookie.  Upon successful login a signed session cookie
is set by the server with the session stored on the server identifying
which account it is logged in as.  The client uses a shared useFetch on
the session endpoint to identify if it's logged in and which account it
is logged in as, and refreshes this when loggin in or out.
This commit is contained in:
Hornwitser 2025-03-07 12:41:57 +01:00
parent abdcc83eb9
commit 150cb82f5c
11 changed files with 276 additions and 4 deletions

View file

@ -1,4 +1,5 @@
<template>
<Header />
<NuxtPage />
</template>

64
components/Header.vue Normal file
View file

@ -0,0 +1,64 @@
<template>
<header>
<nav>
<ul>
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/schedule">Schedule</NuxtLink>
</ul>
</nav>
<div class="account">
<template v-if="session?.account">
{{ session?.account.name || "anonymous" }}
(s:{{ session?.id }} a:{{ session?.account.id }})
{{ session?.account.type }}
<button type="button" @click="logOut">Log out</button>
</template>
<template v-else>
<NuxtLink to="/login">Log In</NuxtLink>
</template>
</div>
</header>
</template>
<script lang="ts" setup>
const { data: session, refresh: sessionRefresh } = useAccountSession();
async function logOut() {
try {
const res = await $fetch.raw("/api/auth/session", {
method: "DELETE",
});
await sessionRefresh();
} catch (err: any) {
alert(`Log out failed: ${err.statusCode} ${err.statusMessage}`);
}
}
</script>
<style scoped>
header {
line-height: 1.5; /* Prevent layout shift from log out button */
display: flex;
column-gap: 1em;
flex-wrap: wrap;
border-bottom: 1px solid var(--foreground);
margin-block-start: 1rem;
}
.account {
display: flex;
justify-content: end;
flex-grow: 1;
column-gap: 0.5em;
flex-wrap: wrap;
}
nav ul {
padding: 0;
display: flex;
column-gap: 0.5em;
}
button {
font-family: inherit;
font-size: inherit;
}
</style>

1
composables/session.ts Normal file
View file

@ -0,0 +1 @@
export const useAccountSession = () => useFetch("/api/auth/session");

33
pages/login.vue Normal file
View file

@ -0,0 +1,33 @@
<template>
<main>
<h1>Log In</h1>
<form @submit.prevent="logIn">
<input v-model="name" type="text" placeholder="Name" required>
<button type="submit">Log In</button>
</form>
<pre><code>{{ result }}</code></pre>
<pre><code>Session: {{ session }}</code></pre>
</main>
</template>
<script lang="ts" setup>
const { data: session, refresh: sessionRefresh } = useAccountSession();
const name = ref("");
const result = ref("")
async function logIn() {
try {
const res = await $fetch.raw("/api/auth/login", {
method: "POST",
body: { name: name.value },
});
result.value = `Server replied: ${res.status} ${res.statusText}`;
await sessionRefresh();
} catch (err: any) {
console.log(err);
console.log(err.data);
result.value = `Server replied: ${err.statusCode} ${err.statusMessage}`;
}
}
</script>

View file

@ -0,0 +1,18 @@
import { readAccounts } from "~/server/database";
export default defineEventHandler(async (event) => {
const { name } = await readBody(event);
if (!name) {
return new Response(undefined, { status: 400 })
}
const accounts = await readAccounts();
const account = accounts.find(a => a.name === name);
if (!account) {
return new Response(undefined, { status: 403 })
}
await setAccountSession(event, account.id);
})

View file

@ -0,0 +1,3 @@
export default defineEventHandler(async (event) => {
await clearAccountSession(event);
})

View file

@ -0,0 +1,14 @@
import { readAccounts } from "~/server/database";
import { AccountSession } from "~/shared/types/account";
export default defineEventHandler(async (event) => {
const session = await getAccountSession(event);
if (!session)
return;
const accounts = await readAccounts();
return {
id: session.id,
account: accounts.find(account => account.id === session.accountId)!,
} satisfies AccountSession;
})

View file

@ -1,13 +1,16 @@
import { readFile, writeFile } from "node:fs/promises";
import { Schedule } from "~/shared/types/schedule";
import { generateDemoSchedule } from "./generate-demo-schedule";
import { Subscription } from "~/shared/types/account";
import { Account, Subscription, Session } from "~/shared/types/account";
import { generateDemoSchedule, generateDemoAccounts } from "./generate-demo-schedule";
// For this demo I'm just storing the runtime data in JSON files. When making
// this into proper application this should be replaced with an actual database.
const schedulePath = "data/schedule.json";
const subscriptionsPath = "data/subscriptions.json";
const accountsPath = "data/accounts.json";
const sessionsPath = "data/sessions.json";
const nextSessionIdPath = "data/next-session-id.json";
async function readJson<T>(filePath: string, fallback: T) {
let data: T extends () => infer R ? R : T;
@ -41,3 +44,25 @@ export async function readSubscriptions() {
export async function writeSubscriptions(subscriptions: Subscription[]) {
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
}
export async function readAccounts() {
return await readJson(accountsPath, generateDemoAccounts);
}
export async function writeAccounts(accounts: Account[]) {
await writeFile(accountsPath, JSON.stringify(accounts, undefined, "\t") + "\n", "utf-8");
}
export async function nextSessionId() {
const nextId = await readJson(nextSessionIdPath, 0);
await writeFile(nextSessionIdPath, String(nextId + 1), "utf-8");
return nextId;
}
export async function readSessions() {
return await readJson<Session[]>(sessionsPath, []);
}
export async function writeSessions(sessions: Session[]) {
await writeFile(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8");
}

View file

@ -1,3 +1,4 @@
import { Account } from "~/shared/types/account";
import { Schedule, TimeSlot } from "~/shared/types/schedule";
const locations = [
@ -112,7 +113,7 @@ function toSlot(origin: Date, id: string, shorthand: string, index: number): Tim
start: toIso(startDate),
end: toIso(endDate),
locations: [location],
}
};
}
export function generateDemoSchedule(): Schedule {
@ -136,5 +137,43 @@ export function generateDemoSchedule(): Schedule {
locations: locations.map(
({ name, description }) => ({ id: toId(name), name, description })
),
}
};
}
const names = [
"Leo", "Lisa",
"Jack", "Emily",
"Roy", "Sofia",
"Adam", "Eve",
"Max", "Rose",
"Hugo", "Maria",
"David", "Zoe",
"Hunter", "Ria",
"Sonny", "Amy",
"Kai", "Megan",
"Toby", "Katie",
"Bob", "Lucy",
];
// MINSTD random implementation for reproducible random numbers.
let seed = 1;
function random() {
const a = 48271;
const c = 0;
const m = 2 ** 31 -1;
return (seed = (a * seed + c) % m | 0) / 2 ** 31;
}
export function generateDemoAccounts(): Account[] {
seed = 1;
const accounts: Account[] = [];
for (const name of names) {
accounts.push({
id: accounts.length,
name,
type: (["regular", "crew", "admin"] as const)[Math.floor(random() ** 5 * 3)],
});
}
return accounts;
}

57
server/utils/session.ts Normal file
View file

@ -0,0 +1,57 @@
import type { H3Event } from "h3";
import { nextSessionId, readSessions, writeSessions } from "~/server/database";
import { Session } from "~/shared/types/account";
async function clearAccountSessionInternal(event: H3Event, sessions: Session[]) {
const existingSessionCookie = await getSignedCookie(event, "session");
if (existingSessionCookie) {
const sessionId = parseInt(existingSessionCookie, 10);
const sessionIndex = sessions.findIndex(session => session.id === sessionId);
if (sessionIndex !== -1) {
sessions.splice(sessionIndex, 1);
return true;
}
}
return false;
}
export async function clearAccountSession(event: H3Event) {
const sessions = await readSessions();
if (await clearAccountSessionInternal(event, sessions)) {
await writeSessions(sessions);
}
setCookie(event, "session", "")
}
export async function setAccountSession(event: H3Event, accountId: number) {
const sessions = await readSessions();
await clearAccountSessionInternal(event, sessions);
const newSession: Session = {
accountId,
id: await nextSessionId(),
};
sessions.push(newSession);
await writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id))
}
export async function getAccountSession(event: H3Event) {
const sessionCookie = await getSignedCookie(event, "session");
if (sessionCookie) {
const sessionId = parseInt(sessionCookie, 10);
const sessions = await readSessions();
return sessions.find(session => session.id === sessionId);
}
}
export async function requireAccountSession(event: H3Event) {
const session = await getAccountSession(event);
if (!session)
throw createError({
status: 401,
message: "Account session required",
});
return session;
}

View file

@ -1,4 +1,21 @@
export interface Account {
id: number,
type: "anonymous" | "regular" | "crew" | "admin",
/** Name of the account. Not present on anonymous accounts */
name?: string,
}
export interface Subscription {
type: "push",
push: PushSubscriptionJSON,
}
export interface Session {
id: number,
accountId: number,
}
export interface AccountSession {
id: number,
account: Account,
}