feat: initial frontend auth handlers & theming
This commit is contained in:
parent
0e2c40b5ca
commit
b1c7fe165e
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
6
web/.prettierignore
Normal file
6
web/.prettierignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
16
web/.prettierrc
Normal file
16
web/.prettierrc
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
15
web/nginx.conf
Normal file
15
web/nginx.conf
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
server {
|
||||||
|
listen 80; # TLS termination is handled by Traefik
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://notatest-server:8080; # Internal Docker DNS
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
2454
web/package-lock.json
generated
Normal file
2454
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
web/package.json
Normal file
37
web/package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "sveltetest",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "prettier --check ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^4.0.0",
|
||||||
|
"@sveltejs/kit": "^2.16.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@tailwindcss/vite": "^4.1.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.1.1",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "^3.2.5",
|
||||||
|
"marked": "^15.0.7"
|
||||||
|
}
|
||||||
|
}
|
166
web/src/app.css
Normal file
166
web/src/app.css
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--light-background: #f5f5f5;
|
||||||
|
--light-foreground: #e0e0e0;
|
||||||
|
--light-accent: #f3b421;
|
||||||
|
--light-text: #222831;
|
||||||
|
|
||||||
|
--dark-background: #222831;
|
||||||
|
--dark-foreground: #393e46;
|
||||||
|
--dark-accent: #ffd369;
|
||||||
|
--dark-text: #eeeeee;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-[var(--light-background)] text-[var(--light-text)] transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
@apply bg-[var(--dark-background)] text-[var(--dark-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
@apply font-medium text-[var(--light-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark h1,
|
||||||
|
.dark h2,
|
||||||
|
.dark h3,
|
||||||
|
.dark h4,
|
||||||
|
.dark h5,
|
||||||
|
.dark h6 {
|
||||||
|
@apply text-[var(--dark-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-[var(--light-accent)] transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
@apply underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark a {
|
||||||
|
@apply text-[var(--dark-accent)];
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
@apply rounded-lg border border-[var(--light-text)]/20 bg-[var(--light-foreground)] px-3 py-2 text-[var(--light-text)] transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
@apply ring-2 ring-[var(--light-accent)] outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input,
|
||||||
|
.dark textarea,
|
||||||
|
.dark select {
|
||||||
|
@apply border-[var(--dark-text)]/20 bg-[var(--dark-foreground)] text-[var(--dark-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input:focus,
|
||||||
|
.dark textarea:focus,
|
||||||
|
.dark select:focus {
|
||||||
|
@apply ring-[var(--dark-accent)];
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply rounded-md bg-[var(--light-accent)] px-4 py-2 text-[var(--light-background)] transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
@apply bg-[var(--light-accent)]/80;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
@apply ring-2 ring-[var(--light-accent)]/50 outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
@apply opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark button {
|
||||||
|
@apply bg-[var(--dark-accent)] text-[var(--dark-background)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark button:hover {
|
||||||
|
@apply bg-[var(--dark-accent)]/80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark button:focus {
|
||||||
|
@apply ring-[var(--dark-accent)]/50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reusable component classes */
|
||||||
|
@layer components {
|
||||||
|
.card {
|
||||||
|
@apply rounded-lg bg-[var(--light-foreground)] p-6 shadow-md transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card {
|
||||||
|
@apply bg-[var(--dark-foreground)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
@apply mb-4 space-y-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
@apply block text-sm font-medium text-[var(--light-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .form-label {
|
||||||
|
@apply text-[var(--dark-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error messages */
|
||||||
|
.error {
|
||||||
|
@apply rounded-lg bg-red-100 p-3 text-sm text-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .error {
|
||||||
|
@apply bg-red-900/30 text-red-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success messages */
|
||||||
|
.success {
|
||||||
|
@apply rounded-lg bg-green-100 p-3 text-sm text-green-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .success {
|
||||||
|
@apply bg-green-900/30 text-green-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-[var(--light-accent)] text-[var(--light-background)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .btn-primary {
|
||||||
|
@apply bg-[var(--dark-accent)] text-[var(--dark-background)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply border border-[var(--light-text)]/20 bg-[var(--light-foreground)] text-[var(--light-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .btn-secondary {
|
||||||
|
@apply border-[var(--dark-text)]/20 bg-[var(--dark-foreground)] text-[var(--dark-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-page {
|
||||||
|
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
}
|
13
web/src/app.d.ts
vendored
Normal file
13
web/src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
12
web/src/app.html
Normal file
12
web/src/app.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
365
web/src/lib/client.ts
Normal file
365
web/src/lib/client.ts
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
import { derived, get, writable, type Writable } from "svelte/store"
|
||||||
|
import { API_BASE_ADDR, AT_EXP_MS, CSRF_EXP_MS, REFRESH_BUF, UUID_REGEX } from "./const"
|
||||||
|
import { goto } from "$app/navigation"
|
||||||
|
import { usersPagination } from "./pages"
|
||||||
|
|
||||||
|
interface UserResponse {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
isAdmin: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentUser: Writable<UserResponse | null> = writable(null)
|
||||||
|
export const accessToken: Writable<string | null> = writable(null)
|
||||||
|
export const csrfToken: Writable<string | null> = writable(null)
|
||||||
|
export const isLoading: Writable<boolean> = writable(false)
|
||||||
|
export const cError: Writable<string | null> = writable(null)
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private baseUrl: string
|
||||||
|
private lastAtUpdate = new Date(0)
|
||||||
|
private lastCsrfUpdate = new Date(0)
|
||||||
|
private refreshInProgress = false
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRequest<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: { useBearerAuth: boolean }
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
isLoading.set(true)
|
||||||
|
cError.set(null)
|
||||||
|
|
||||||
|
// If `handleResponse` is used, errors thrown from it will be caught here
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only bearers (JWT access tokens) need to be considered here as the refresh token cookies
|
||||||
|
// will be automatically included to corresponding requests by browser and CSRF rotation is
|
||||||
|
// handled by the `refreshAccessToken` method
|
||||||
|
if (options.useBearerAuth) {
|
||||||
|
await this.checkAndRefreshAccessToken()
|
||||||
|
}
|
||||||
|
return await fn()
|
||||||
|
} catch (err) {
|
||||||
|
cError.set(err instanceof Error ? err.message : "Unknown error")
|
||||||
|
console.log(`error: ${get(cError)}`)
|
||||||
|
} finally {
|
||||||
|
isLoading.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be attached to routes that handle authentication with the bearer token (access token)
|
||||||
|
private async handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// This should never happen due to the token expiration checks we make client-side,
|
||||||
|
// but it's still good to have as a fallback
|
||||||
|
try {
|
||||||
|
console.log("unexpected 401 caught, attempting refresh")
|
||||||
|
await this.refreshAccessToken()
|
||||||
|
} catch (err) {
|
||||||
|
console.log("refresh attempt not successful")
|
||||||
|
await this.handleLocalLogout()
|
||||||
|
throw new Error("Session expired, please authenticate again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize the error message and display (only if not 401)
|
||||||
|
const { error } = await response.json()
|
||||||
|
console.log(`response error: ${error}`)
|
||||||
|
const dError = error[0].toUpperCase() + error.substr(1).toLowerCase() + "."
|
||||||
|
|
||||||
|
throw new Error(dError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkAndRefreshAccessToken(): Promise<void> {
|
||||||
|
if (this.refreshInProgress) return
|
||||||
|
|
||||||
|
const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime()
|
||||||
|
const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF
|
||||||
|
|
||||||
|
console.log(`timeSinceUpdate: ${timeSinceUpdate}`)
|
||||||
|
|
||||||
|
if (needsRefresh) {
|
||||||
|
console.log("running token refresh attempt")
|
||||||
|
|
||||||
|
this.refreshInProgress = true
|
||||||
|
await this.refreshAccessToken()
|
||||||
|
this.refreshInProgress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAccessToken(): Promise<void> {
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...(await this.getCsrfHeader())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await this.handleLocalLogout()
|
||||||
|
throw new Error("Refreshing token failed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken: newToken } = await response.json()
|
||||||
|
accessToken.set(newToken)
|
||||||
|
this.lastAtUpdate = new Date()
|
||||||
|
},
|
||||||
|
{ useBearerAuth: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCsrfHeader(): Promise<HeadersInit> {
|
||||||
|
let token = get(csrfToken)
|
||||||
|
const timeSinceUpdate = Date.now() - this.lastCsrfUpdate.getTime()
|
||||||
|
const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF
|
||||||
|
|
||||||
|
if (!token || needsRefresh) {
|
||||||
|
await this.refreshCsrfToken()
|
||||||
|
token = get(csrfToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { "X-CSRF-Token": token || "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshCsrfToken(): Promise<void> {
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/cookie/csrf`, {
|
||||||
|
credentials: "include"
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await this.handleLocalLogout()
|
||||||
|
throw new Error("Fetching CSRF token failed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = response.headers.get("X-CSRF-Token")
|
||||||
|
csrfToken.set(newToken)
|
||||||
|
this.lastCsrfUpdate = new Date()
|
||||||
|
},
|
||||||
|
{ useBearerAuth: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLocalLogout(): Promise<void> {
|
||||||
|
this.lastAtUpdate = new Date(0)
|
||||||
|
this.lastCsrfUpdate = new Date(0)
|
||||||
|
currentUser.set(null)
|
||||||
|
accessToken.set(null)
|
||||||
|
csrfToken.set(null)
|
||||||
|
await goto("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthHeader(): HeadersInit {
|
||||||
|
const token = get(accessToken)
|
||||||
|
return { Authorization: `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async register(username: string, password: string): Promise<void> {
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Can't overwrite `username` parameter
|
||||||
|
const data = await this.handleResponse<{
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
}>(response)
|
||||||
|
|
||||||
|
console.log(`'${data.username} -> ${data.id}'`)
|
||||||
|
await goto("/login")
|
||||||
|
},
|
||||||
|
{ useBearerAuth: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async login(username: string, password: string): Promise<void> {
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append("includeUser", "true")
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/login?${params}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { accessToken: token, user } = await this.handleResponse<{
|
||||||
|
accessToken: string
|
||||||
|
user: UserResponse
|
||||||
|
}>(response)
|
||||||
|
|
||||||
|
accessToken.set(token)
|
||||||
|
currentUser.set(user || null)
|
||||||
|
this.lastAtUpdate = new Date()
|
||||||
|
|
||||||
|
console.log(user)
|
||||||
|
},
|
||||||
|
{ useBearerAuth: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async logout(): Promise<void> {
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...this.getAuthHeader()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
console.log("logout successful")
|
||||||
|
await this.handleLocalLogout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.handleResponse<void>(response)
|
||||||
|
await this.handleLocalLogout()
|
||||||
|
},
|
||||||
|
{ useBearerAuth: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentUser(): Promise<void> {
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
...this.getAuthHeader()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const user = await this.handleResponse<UserResponse>(response)
|
||||||
|
currentUser.set(user)
|
||||||
|
},
|
||||||
|
{ useBearerAuth: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateCurrentUserPassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const data = {
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
}
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/owner`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
...this.getAuthHeader()
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
const { accessToken: token, user } = await this.handleResponse<{
|
||||||
|
accessToken: string
|
||||||
|
user: UserResponse
|
||||||
|
}>(response)
|
||||||
|
accessToken.set(token)
|
||||||
|
currentUser.set(user || null)
|
||||||
|
this.lastAtUpdate = new Date()
|
||||||
|
|
||||||
|
console.log(user)
|
||||||
|
},
|
||||||
|
{ useBearerAuth: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteCurrentUser(password: string): Promise<void> {
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/owner`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
...this.getAuthHeader()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
console.log("deletion succesful")
|
||||||
|
await this.handleLocalLogout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.handleResponse<void>(response)
|
||||||
|
await this.handleLocalLogout()
|
||||||
|
},
|
||||||
|
{ useBearerAuth: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async adminListAll(): Promise<UserResponse[] | undefined> {
|
||||||
|
const user = get(currentUser)
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
throw new Error("Admin privileges required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append("limit", `${get(usersPagination).pageSize}`)
|
||||||
|
params.append("offset", `${get(usersPagination).currentPage}`)
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/admin/all`, {
|
||||||
|
headers: {
|
||||||
|
...this.getAuthHeader()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const users = await this.handleResponse<UserResponse[]>(response)
|
||||||
|
console.log(`admin: got ${users.length} results`)
|
||||||
|
|
||||||
|
return users
|
||||||
|
},
|
||||||
|
{ useBearerAuth: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async adminDeleteUser(userID: string): Promise<void> {
|
||||||
|
const user = get(currentUser)
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
throw new Error("Admin privileges required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!UUID_REGEX.test(userID)) {
|
||||||
|
throw new Error("Invalid user ID format.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.handleRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/admin/${userID}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
...this.getAuthHeader()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
console.log("admin: deletion successful")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.handleResponse<void>(response)
|
||||||
|
},
|
||||||
|
{ useBearerAuth: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient(API_BASE_ADDR)
|
144
web/src/lib/components/AuthForm.svelte
Normal file
144
web/src/lib/components/AuthForm.svelte
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cError } from "$lib/client"
|
||||||
|
import {
|
||||||
|
ENTROPY_CLASSES,
|
||||||
|
MAX_PASSWORD_LENGTH,
|
||||||
|
MIN_PASSWORD_ENTROPY,
|
||||||
|
MIN_PASSWORD_LENGTH
|
||||||
|
} from "$lib/const"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
export let formName: string
|
||||||
|
export let handler: (username: string, password: string) => Promise<void>
|
||||||
|
export let bottomText: string
|
||||||
|
export let bottomLink: string
|
||||||
|
|
||||||
|
let username = ""
|
||||||
|
let password = ""
|
||||||
|
let passwordError = ""
|
||||||
|
let isFormValid = false
|
||||||
|
|
||||||
|
// Clear any errors when swapping between login/signup views
|
||||||
|
onMount(() => cError.set(null))
|
||||||
|
|
||||||
|
const calculateEntropy = (password: string) => {
|
||||||
|
let poolSize = 0
|
||||||
|
|
||||||
|
for (const [eClass, poolPlus] of ENTROPY_CLASSES) {
|
||||||
|
if (eClass.test(password)) {
|
||||||
|
poolSize += poolPlus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty password exception
|
||||||
|
if (poolSize === 0) return 0
|
||||||
|
|
||||||
|
const uniqueChars = new Set(password.split("")).size
|
||||||
|
|
||||||
|
const basicEntropy = password.length * Math.log2(poolSize)
|
||||||
|
const diversityAdjustedEntropy =
|
||||||
|
Math.log2(poolSize) + (password.length - 1) * Math.log2(uniqueChars)
|
||||||
|
|
||||||
|
return Math.min(basicEntropy, diversityAdjustedEntropy)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatePassword = () => {
|
||||||
|
if (formName === "Login") {
|
||||||
|
// Skip if logging into existing account
|
||||||
|
passwordError = ""
|
||||||
|
isFormValid = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
passwordError = `Password cannot be shorter than ${MIN_PASSWORD_LENGTH} characters`
|
||||||
|
isFormValid = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
passwordError = `Password cannot be longer than ${MAX_PASSWORD_LENGTH} characters`
|
||||||
|
isFormValid = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entropy = calculateEntropy(password)
|
||||||
|
console.log(`entropy: ${entropy}`)
|
||||||
|
if (entropy < MIN_PASSWORD_ENTROPY) {
|
||||||
|
passwordError =
|
||||||
|
"Password is not complex enough (add uppercase, lowercase, numbers, and symbols)"
|
||||||
|
isFormValid = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordError = ""
|
||||||
|
isFormValid = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update validation on password change (reactive dependency)
|
||||||
|
$: {
|
||||||
|
password
|
||||||
|
validatePassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
cError.set(null)
|
||||||
|
|
||||||
|
if (formName === "Register" && !isFormValid) {
|
||||||
|
cError.set(passwordError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler(username, password)
|
||||||
|
} catch (err) {
|
||||||
|
cError.set(err instanceof Error ? err.message : "Authentication failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen items-center justify-center px-4">
|
||||||
|
<div class="card w-full max-w-md space-y-6">
|
||||||
|
<h1 class="text-center text-3xl font-bold">{formName}</h1>
|
||||||
|
|
||||||
|
{#if $cError}
|
||||||
|
<div class="error">
|
||||||
|
{$cError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="space-y-6" on:submit|preventDefault={handleSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username" class="form-label"> Username </label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
bind:value={username}
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password" class="form-label"> Password </label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
autocomplete={formName === "Login" ? "current-password" : "new-password"}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full">
|
||||||
|
{formName}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center text-sm font-medium">
|
||||||
|
{bottomText}
|
||||||
|
<a href={bottomLink}> here </a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
46
web/src/lib/components/ThemeToggle.svelte
Normal file
46
web/src/lib/components/ThemeToggle.svelte
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Moon from "$lib/icons/Moon.svelte"
|
||||||
|
import Sun from "$lib/icons/Sun.svelte"
|
||||||
|
import { themeState } from "$lib/state.svelte"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
|
||||||
|
// The `themeState` rune can't be imported here so we must explicitly call the setup
|
||||||
|
onMount(() => {
|
||||||
|
themeState.isDarkMode = localStorage.getItem("darkMode") === "true"
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<script lang="ts">
|
||||||
|
if (document) {
|
||||||
|
let darkMode = false
|
||||||
|
const savedTheme = localStorage.getItem("darkMode")
|
||||||
|
|
||||||
|
if (savedTheme !== null) {
|
||||||
|
darkMode = savedTheme === "true"
|
||||||
|
} else {
|
||||||
|
// Fallback to system preference
|
||||||
|
darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
localStorage.setItem("darkMode", darkMode ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.classList.toggle("dark", darkMode)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={() => {
|
||||||
|
themeState.isDarkMode = !themeState.isDarkMode
|
||||||
|
document.documentElement.classList.toggle("dark", themeState.isDarkMode)
|
||||||
|
localStorage.setItem("darkMode", themeState.isDarkMode ? "true" : "false")
|
||||||
|
}}
|
||||||
|
class="btn-secondary rounded-full p-2"
|
||||||
|
aria-label={themeState.isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
>
|
||||||
|
{#if themeState.isDarkMode}
|
||||||
|
<Moon />
|
||||||
|
{:else}
|
||||||
|
<Sun />
|
||||||
|
{/if}
|
||||||
|
</button>
|
24
web/src/lib/const.ts
Normal file
24
web/src/lib/const.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// TODO: this can be set to always be "/api" after Dockerization as the backend requests
|
||||||
|
// will automatically be proxied to the correct destination
|
||||||
|
export const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api"
|
||||||
|
|
||||||
|
// Probably shouldn't be hardcoded, but instead read from .env
|
||||||
|
export const AT_EXP_MS = 15 * 60 * 1000 // 15 min.
|
||||||
|
export const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h.
|
||||||
|
export const REFRESH_BUF = 30 * 1000 // 30 s.
|
||||||
|
|
||||||
|
export const MIN_PASSWORD_LENGTH = 12
|
||||||
|
export const MAX_PASSWORD_LENGTH = 72
|
||||||
|
export const MIN_PASSWORD_ENTROPY = 60.0
|
||||||
|
|
||||||
|
export const UUID_REGEX = new RegExp(
|
||||||
|
"/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Underestimation compared to the backend to prevent mismatches when registering
|
||||||
|
export const ENTROPY_CLASSES: Array<[RegExp, number]> = [
|
||||||
|
[/[a-z]/, 24], // 26
|
||||||
|
[/[A-Z]/, 24], // 26
|
||||||
|
[/\d/, 10],
|
||||||
|
[/[!@#$%^&*()\-_+=\[\]{}|;:'",.<>\/?`~\\]/, 32] // 40
|
||||||
|
]
|
3
web/src/lib/icons/Moon.svelte
Normal file
3
web/src/lib/icons/Moon.svelte
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 184 B |
7
web/src/lib/icons/Sun.svelte
Normal file
7
web/src/lib/icons/Sun.svelte
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 666 B |
20
web/src/lib/pages.ts
Normal file
20
web/src/lib/pages.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { writable } from "svelte/store"
|
||||||
|
|
||||||
|
interface PaginationState {
|
||||||
|
currentPage: number
|
||||||
|
pageSize: number
|
||||||
|
totalItems?: number // Page number rendering (+ caching metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit and offset (`pageSize` and `currentPage`) values align initially with backend defaults,
|
||||||
|
// but should be adjusted based on the UI state/dimensions
|
||||||
|
|
||||||
|
export const notesPagination = writable<PaginationState>({
|
||||||
|
currentPage: 0,
|
||||||
|
pageSize: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
export const usersPagination = writable<PaginationState>({
|
||||||
|
currentPage: 0,
|
||||||
|
pageSize: 50
|
||||||
|
})
|
3
web/src/lib/state.svelte.ts
Normal file
3
web/src/lib/state.svelte.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const themeState = $state({
|
||||||
|
isDarkMode: false
|
||||||
|
})
|
12
web/src/routes/+layout.svelte
Normal file
12
web/src/routes/+layout.svelte
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte"
|
||||||
|
import "../app.css"
|
||||||
|
|
||||||
|
let { children } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
|
||||||
|
<div class="absolute top-4 right-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
2
web/src/routes/+page.svelte
Normal file
2
web/src/routes/+page.svelte
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<h1>Welcome to SvelteKit</h1>
|
||||||
|
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
10
web/src/routes/login/+page.svelte
Normal file
10
web/src/routes/login/+page.svelte
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AuthForm from "$lib/components/AuthForm.svelte"
|
||||||
|
import { apiClient } from "$lib/client"
|
||||||
|
|
||||||
|
const loginHandler = (username: string, password: string) => {
|
||||||
|
return apiClient.login(username, password)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AuthForm formName="Login" handler={loginHandler} bottomText="Register" bottomLink="/register" />
|
0
web/src/routes/notes/+page.svelte
Normal file
0
web/src/routes/notes/+page.svelte
Normal file
10
web/src/routes/register/+page.svelte
Normal file
10
web/src/routes/register/+page.svelte
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AuthForm from "$lib/components/AuthForm.svelte"
|
||||||
|
import { apiClient } from "$lib/client"
|
||||||
|
|
||||||
|
const registerHandler = (username: string, password: string) => {
|
||||||
|
return apiClient.register(username, password)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AuthForm formName="Register" handler={registerHandler} bottomText="Login" bottomLink="/login" />
|
BIN
web/static/favicon.png
Normal file
BIN
web/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
18
web/svelte.config.js
Normal file
18
web/svelte.config.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
6
web/tailwind.config.cjs
Normal file
6
web/tailwind.config.cjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||||
|
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
|
||||||
|
theme: {}
|
||||||
|
}
|
19
web/tsconfig.json
Normal file
19
web/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
7
web/vite.config.ts
Normal file
7
web/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
|
import { sveltekit } from "@sveltejs/kit/vite"
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()]
|
||||||
|
})
|
Loading…
x
Reference in New Issue
Block a user