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:
parent
abdcc83eb9
commit
150cb82f5c
11 changed files with 276 additions and 4 deletions
1
app.vue
1
app.vue
|
@ -1,4 +1,5 @@
|
|||
<template>
|
||||
<Header />
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
|
|
64
components/Header.vue
Normal file
64
components/Header.vue
Normal 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
1
composables/session.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const useAccountSession = () => useFetch("/api/auth/session");
|
33
pages/login.vue
Normal file
33
pages/login.vue
Normal 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>
|
18
server/api/auth/login.post.ts
Normal file
18
server/api/auth/login.post.ts
Normal 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);
|
||||
})
|
3
server/api/auth/session.delete.ts
Normal file
3
server/api/auth/session.delete.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
await clearAccountSession(event);
|
||||
})
|
14
server/api/auth/session.get.ts
Normal file
14
server/api/auth/session.get.ts
Normal 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;
|
||||
})
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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
57
server/utils/session.ts
Normal 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;
|
||||
}
|
17
shared/types/account.d.ts
vendored
17
shared/types/account.d.ts
vendored
|
@ -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,
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue