feat(auth): add user authentication with roles, HMAC-signed cookies, and login/register UI
- Add `users` PocketBase collection (username, password_hash, role, created) - Implement HMAC-SHA256 signed cookie auth in hooks.server.ts; token payload is userId:username:role - Add User type, getUserByUsername, createUser (scrypt), loginUser (timing-safe) to pocketbase.ts - Add login/register page with tabbed form UI and server actions - Add logout route that clears the auth cookie - Add layout.server.ts auth guard: redirect unauthenticated users to /login - Extend App.Locals and App.PageData with role field - Add AUTH_SECRET, POCKETBASE_ADMIN_EMAIL/PASSWORD to .env.example - Install @types/node for Node crypto/scrypt types
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
// ranking_html — page(number,unique), html(text), updated(date)
|
||||
// progress — session_id(text), slug(text), chapter(number), updated(date)
|
||||
// audio_cache — cache_key(text,unique), filename(text), updated(date)
|
||||
// users — username(text,unique), password_hash(text), role(text), created(date)
|
||||
package storage
|
||||
|
||||
import (
|
||||
@@ -312,6 +313,16 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
||||
{"name": "updated", "type": "date"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"type": "base",
|
||||
"schema": []map[string]interface{}{
|
||||
{"name": "username", "type": "text", "required": true, "options": map[string]interface{}{"min": 3, "max": 32}},
|
||||
{"name": "password_hash", "type": "text", "required": true},
|
||||
{"name": "role", "type": "text"},
|
||||
{"name": "created", "type": "date"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, col := range collections {
|
||||
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections", col)
|
||||
|
||||
@@ -8,6 +8,13 @@ SCRAPER_API_URL=http://localhost:8080
|
||||
# Public URL of PocketBase (used by SvelteKit server-side load functions)
|
||||
POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# PocketBase admin credentials (server-side only, never exposed to browser)
|
||||
POCKETBASE_ADMIN_EMAIL=admin@libnovel.local
|
||||
POCKETBASE_ADMIN_PASSWORD=changeme123
|
||||
|
||||
# Public-facing MinIO URL (used to rewrite presigned URLs for the browser)
|
||||
# In dev this is localhost; in prod set to your MinIO public domain
|
||||
PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000
|
||||
|
||||
# Secret used to sign auth tokens stored in cookies (generate with: openssl rand -hex 32)
|
||||
AUTH_SECRET=change_this_to_a_long_random_secret
|
||||
|
||||
18
ui/package-lock.json
generated
18
ui/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
@@ -1389,6 +1390,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
@@ -2356,6 +2367,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
||||
5
ui/src/app.d.ts
vendored
5
ui/src/app.d.ts
vendored
@@ -5,8 +5,11 @@ declare global {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
sessionId: string;
|
||||
user: { id: string; username: string; role: string } | null;
|
||||
}
|
||||
interface PageData {
|
||||
user?: { id: string; username: string; role: string } | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,72 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { randomBytes, createHmac } from 'node:crypto';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SESSION_COOKIE = 'libnovel_session';
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
let sessionId = event.cookies.get(SESSION_COOKIE);
|
||||
const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production';
|
||||
|
||||
// ─── Token helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sign a payload string with HMAC-SHA256 using AUTH_SECRET.
|
||||
* Returns "<payload>.<signature>".
|
||||
*/
|
||||
export function signToken(payload: string): string {
|
||||
const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed token. Returns the payload string on success, null on failure.
|
||||
*/
|
||||
export function verifyToken(token: string): string | null {
|
||||
const lastDot = token.lastIndexOf('.');
|
||||
if (lastDot < 0) return null;
|
||||
const payload = token.slice(0, lastDot);
|
||||
const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex');
|
||||
const actual = token.slice(lastDot + 1);
|
||||
// constant-time comparison
|
||||
if (expected.length !== actual.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i);
|
||||
}
|
||||
return diff === 0 ? payload : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed auth token for a user.
|
||||
* Payload format: "<userId>:<username>:<role>"
|
||||
*/
|
||||
export function createAuthToken(userId: string, username: string, role: string): string {
|
||||
return signToken(`${userId}:${username}:${role}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a verified auth token into user data. Returns null if invalid.
|
||||
*/
|
||||
export function parseAuthToken(token: string): { id: string; username: string; role: string } | null {
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
const firstColon = payload.indexOf(':');
|
||||
if (firstColon < 0) return null;
|
||||
const secondColon = payload.indexOf(':', firstColon + 1);
|
||||
if (secondColon < 0) return null;
|
||||
const id = payload.slice(0, firstColon);
|
||||
const username = payload.slice(firstColon + 1, secondColon);
|
||||
const role = payload.slice(secondColon + 1);
|
||||
if (!id || !username) return null;
|
||||
return { id, username, role };
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Anonymous session cookie (for reading progress)
|
||||
let sessionId = event.cookies.get(SESSION_COOKIE) ?? '';
|
||||
if (!sessionId) {
|
||||
sessionId = randomBytes(16).toString('hex');
|
||||
event.cookies.set(SESSION_COOKIE, sessionId, {
|
||||
@@ -16,8 +76,12 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
}
|
||||
|
||||
event.locals.sessionId = sessionId;
|
||||
|
||||
// Auth cookie → resolve logged-in user
|
||||
const authToken = event.cookies.get(AUTH_COOKIE);
|
||||
event.locals.user = authToken ? parseAuthToken(authToken) : null;
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ export interface Progress {
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
|
||||
let _token = '';
|
||||
@@ -167,3 +175,64 @@ export async function setProgress(sessionId: string, slug: string, chapter: numb
|
||||
await pbPost('/api/collections/progress/records', payload);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(password, salt, 64).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const derived = scryptSync(password, salt, 64);
|
||||
const hashBuf = Buffer.from(hash, 'hex');
|
||||
if (derived.length !== hashBuf.length) return false;
|
||||
return timingSafeEqual(derived, hashBuf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by username. Returns null if not found.
|
||||
*/
|
||||
export async function getUserByUsername(username: string): Promise<User | null> {
|
||||
return listOne<User>('users', `username="${username.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with a hashed password. Throws if username already exists.
|
||||
*/
|
||||
export async function createUser(username: string, password: string, role = 'user'): Promise<User> {
|
||||
const existing = await getUserByUsername(username);
|
||||
if (existing) {
|
||||
throw new Error('Username already taken');
|
||||
}
|
||||
const password_hash = hashPassword(password);
|
||||
const res = await pbPost('/api/collections/users/records', {
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
created: new Date().toISOString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Failed to create user: ${res.status} ${body}`);
|
||||
}
|
||||
return res.json() as Promise<User>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify username + password. Returns the user on success, null on failure.
|
||||
*/
|
||||
export async function loginUser(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<User | null> {
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user) return null;
|
||||
if (!verifyPassword(password, user.password_hash)) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
15
ui/src/routes/+layout.server.ts
Normal file
15
ui/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
// Routes that are accessible without being logged in
|
||||
const PUBLIC_ROUTES = new Set(['/login']);
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) {
|
||||
redirect(302, `/login`);
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
101
ui/src/routes/login/+page.server.ts
Normal file
101
ui/src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { loginUser, createUser } from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../hooks.server';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Already logged in — send to library
|
||||
if (locals.user) {
|
||||
redirect(302, '/books');
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
login: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const username = (data.get('username') as string | null)?.trim() ?? '';
|
||||
const password = (data.get('password') as string | null) ?? '';
|
||||
|
||||
if (!username || !password) {
|
||||
return fail(400, { action: 'login', error: 'Username and password are required.' });
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await loginUser(username, password);
|
||||
} catch {
|
||||
return fail(500, { action: 'login', error: 'An error occurred. Please try again.' });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return fail(401, { action: 'login', error: 'Invalid username or password.' });
|
||||
}
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user');
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
redirect(302, '/books');
|
||||
},
|
||||
|
||||
register: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const username = (data.get('username') as string | null)?.trim() ?? '';
|
||||
const password = (data.get('password') as string | null) ?? '';
|
||||
const confirm = (data.get('confirm') as string | null) ?? '';
|
||||
|
||||
if (!username || !password) {
|
||||
return fail(400, { action: 'register', error: 'Username and password are required.' });
|
||||
}
|
||||
if (username.length < 3 || username.length > 32) {
|
||||
return fail(400, {
|
||||
action: 'register',
|
||||
error: 'Username must be between 3 and 32 characters.'
|
||||
});
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
return fail(400, {
|
||||
action: 'register',
|
||||
error: 'Username may only contain letters, numbers, underscores and hyphens.'
|
||||
});
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return fail(400, {
|
||||
action: 'register',
|
||||
error: 'Password must be at least 8 characters.'
|
||||
});
|
||||
}
|
||||
if (password !== confirm) {
|
||||
return fail(400, { action: 'register', error: 'Passwords do not match.' });
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await createUser(username, password);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Registration failed.';
|
||||
if (msg.includes('Username already taken')) {
|
||||
return fail(409, { action: 'register', error: 'That username is already taken.' });
|
||||
}
|
||||
return fail(500, { action: 'register', error: 'An error occurred. Please try again.' });
|
||||
}
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user');
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
redirect(302, '/books');
|
||||
}
|
||||
};
|
||||
136
ui/src/routes/login/+page.svelte
Normal file
136
ui/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
|
||||
let mode: 'login' | 'register' = $state('login');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign in — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Tab switcher -->
|
||||
<div class="flex mb-6 border-b border-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode = 'login')}
|
||||
class="flex-1 pb-3 text-sm font-medium transition-colors
|
||||
{mode === 'login'
|
||||
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
|
||||
: 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode = 'register')}
|
||||
class="flex-1 pb-3 text-sm font-medium transition-colors
|
||||
{mode === 'register'
|
||||
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
|
||||
: 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if form?.error && (form?.action === mode || !form?.action)}
|
||||
<div class="mb-4 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'login'}
|
||||
<form method="POST" action="?/login" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="login-username" class="block text-xs text-zinc-400 mb-1">Username</label>
|
||||
<input
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="your_username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-password" class="block text-xs text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="POST" action="?/register" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="reg-username" class="block text-xs text-zinc-400 mb-1">Username</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="32"
|
||||
pattern="[a-zA-Z0-9_\-]+"
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="your_username"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-500">3–32 characters: letters, numbers, _ or -</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reg-password" class="block text-xs text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-500">At least 8 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reg-confirm" class="block text-xs text-zinc-400 mb-1">Confirm password</label>
|
||||
<input
|
||||
id="reg-confirm"
|
||||
name="confirm"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
11
ui/src/routes/logout/+page.server.ts
Normal file
11
ui/src/routes/logout/+page.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ cookies }) => {
|
||||
cookies.delete(AUTH_COOKIE, { path: '/' });
|
||||
redirect(302, '/login');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user