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)
|
// ranking_html — page(number,unique), html(text), updated(date)
|
||||||
// progress — session_id(text), slug(text), chapter(number), updated(date)
|
// progress — session_id(text), slug(text), chapter(number), updated(date)
|
||||||
// audio_cache — cache_key(text,unique), filename(text), 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
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -312,6 +313,16 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
|||||||
{"name": "updated", "type": "date"},
|
{"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 {
|
for _, col := range collections {
|
||||||
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections", col)
|
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)
|
# Public URL of PocketBase (used by SvelteKit server-side load functions)
|
||||||
POCKETBASE_URL=http://localhost:8090
|
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)
|
# 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
|
# In dev this is localhost; in prod set to your MinIO public domain
|
||||||
PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000
|
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/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
@@ -1389,6 +1390,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
@@ -2356,6 +2367,13 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"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 Error {}
|
||||||
interface Locals {
|
interface Locals {
|
||||||
sessionId: string;
|
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 PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,72 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
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 SESSION_COOKIE = 'libnovel_session';
|
||||||
|
const AUTH_COOKIE = 'libnovel_auth';
|
||||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production';
|
||||||
let sessionId = event.cookies.get(SESSION_COOKIE);
|
|
||||||
|
|
||||||
|
// ─── 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) {
|
if (!sessionId) {
|
||||||
sessionId = randomBytes(16).toString('hex');
|
sessionId = randomBytes(16).toString('hex');
|
||||||
event.cookies.set(SESSION_COOKIE, sessionId, {
|
event.cookies.set(SESSION_COOKIE, sessionId, {
|
||||||
@@ -16,8 +76,12 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
maxAge: ONE_YEAR
|
maxAge: ONE_YEAR
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
event.locals.sessionId = sessionId;
|
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);
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export interface Progress {
|
|||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
role: string;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _token = '';
|
let _token = '';
|
||||||
@@ -167,3 +175,64 @@ export async function setProgress(sessionId: string, slug: string, chapter: numb
|
|||||||
await pbPost('/api/collections/progress/records', payload);
|
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