🪻 distributed transcription service thistle.dunkirk.sh

feat: add login/auth and settings

dunkirk.sh 8dd6999b 69133d91

verified
+1
.gitignore
···
node_modules
+
thistle.db
+62 -8
CRUSH.md
···
- Language: TypeScript with strict mode
- Frontend: Vanilla HTML/CSS/JS with lightweight helpers on top of web components
+
## Design System
+
+
ALWAYS use the project's CSS variables for colors:
+
+
```css
+
:root {
+
/* Color palette */
+
--gunmetal: #2d3142ff; /* dark blue-gray */
+
--paynes-gray: #4f5d75ff; /* medium blue-gray */
+
--silver: #bfc0c0ff; /* light gray */
+
--white: #ffffffff; /* white */
+
--coral: #ef8354ff; /* warm orange */
+
+
/* Semantic color assignments */
+
--text: var(--gunmetal);
+
--background: var(--white);
+
--primary: var(--paynes-gray);
+
--secondary: var(--silver);
+
--accent: var(--coral);
+
}
+
```
+
+
**Color usage:**
+
- NEVER hardcode colors like `#4f46e5`, `white`, `red`, etc.
+
- Always use semantic variables (`var(--primary)`, `var(--background)`, `var(--accent)`, etc.) or named color variables (`var(--gunmetal)`, `var(--coral)`, etc.)
+
+
**Dimensions:**
+
- Use `rem` for all sizes, spacing, and widths (not `px`)
+
- Base font size is 16px (1rem = 16px)
+
- Common values: `0.5rem` (8px), `1rem` (16px), `2rem` (32px), `3rem` (48px)
+
- Max widths: `48rem` (768px) for content, `56rem` (896px) for forms/data
+
- Spacing scale: `0.25rem`, `0.5rem`, `0.75rem`, `1rem`, `1.5rem`, `2rem`, `3rem`
+
## NO FRAMEWORKS
NEVER use React, Vue, Svelte, or any heavy framework.
···
```html
<!DOCTYPE html>
-
<html>
-
<head>
-
<link rel="stylesheet" href="./styles.css">
-
</head>
-
<body>
-
<h1>Hello, world!</h1>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Page Title - Thistle</title>
+
<link rel="icon"
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
+
<link rel="stylesheet" href="../styles/main.css">
+
</head>
+
+
<body>
+
<auth-component></auth-component>
+
+
<main>
+
<h1>Page Title</h1>
<my-component></my-component>
-
<script type="module" src="./frontend.ts"></script>
-
</body>
+
</main>
+
+
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="../components/my-component.ts"></script>
+
</body>
+
</html>
```
+
+
**Standard HTML template:**
+
- Always include the `<auth-component>` element for consistent login/logout UI
+
- Always include the thistle emoji favicon
+
- Always include proper meta tags (charset, viewport)
+
- Structure: auth component, then main content, then scripts
+
- Import `auth.ts` on every page for authentication UI
Bun's bundler will transpile and bundle automatically. `<link>` tags pointing to stylesheets work with Bun's CSS bundler.
+9
bun.lock
···
"name": "inky",
"dependencies": {
"lit": "^3.3.1",
+
"ua-parser-js": "^2.0.6",
},
"devDependencies": {
"@biomejs/biome": "^2.3.2",
···
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
"detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="],
+
+
"is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="],
+
"lit": ["lit@3.3.1", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA=="],
"lit-element": ["lit-element@4.2.1", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw=="],
···
"lit-html": ["lit-html@3.3.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+
"ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="],
+
+
"ua-parser-js": ["ua-parser-js@2.0.6", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
+2 -1
package.json
···
"typescript": "^5"
},
"dependencies": {
-
"lit": "^3.3.1"
+
"lit": "^3.3.1",
+
"ua-parser-js": "^2.0.6"
}
}
+491
src/components/auth.ts
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, state } from "lit/decorators.js";
+
+
interface User {
+
email: string;
+
name: string | null;
+
avatar: string;
+
}
+
+
@customElement("auth-component")
+
export class AuthComponent extends LitElement {
+
@state() user: User | null = null;
+
@state() loading = true;
+
@state() showModal = false;
+
@state() email = "";
+
@state() password = "";
+
@state() name = "";
+
@state() error = "";
+
@state() isSubmitting = false;
+
@state() needsRegistration = false;
+
+
static override styles = css`
+
:host {
+
display: block;
+
position: fixed;
+
top: 2rem;
+
right: 2rem;
+
z-index: 1000;
+
}
+
+
.auth-button {
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
padding: 0.5rem 1rem;
+
background: var(--primary);
+
color: white;
+
border: 2px solid var(--primary);
+
border-radius: 8px;
+
cursor: pointer;
+
font-size: 1rem;
+
font-weight: 500;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.auth-button:hover {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
.auth-button:hover .email {
+
color: var(--primary);
+
}
+
+
.auth-button img {
+
transition: all 0.2s;
+
}
+
+
.auth-button:hover img {
+
opacity: 0.8;
+
}
+
+
.user-info {
+
display: flex;
+
align-items: center;
+
gap: 0.75rem;
+
}
+
+
.email {
+
font-weight: 500;
+
color: white;
+
font-size: 0.875rem;
+
transition: all 0.2s;
+
}
+
+
.modal-overlay {
+
position: fixed;
+
top: 0;
+
left: 0;
+
right: 0;
+
bottom: 0;
+
background: rgba(0, 0, 0, 0.5);
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
z-index: 2000;
+
padding: 1rem;
+
}
+
+
.modal {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 12px;
+
padding: 2rem;
+
max-width: 400px;
+
width: 100%;
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
+
}
+
+
.modal h2 {
+
margin-top: 0;
+
color: var(--text);
+
font-size: 1.777rem;
+
}
+
+
.modal form {
+
display: flex;
+
flex-direction: column;
+
gap: 1rem;
+
}
+
+
.field {
+
display: flex;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.field label {
+
font-weight: 500;
+
color: var(--text);
+
}
+
+
.field input {
+
padding: 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
}
+
+
.field input:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.error {
+
color: var(--accent);
+
font-size: 0.875rem;
+
margin: 0;
+
}
+
+
.btn {
+
padding: 0.75rem 1.5rem;
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
border: 2px solid;
+
}
+
+
.btn:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
.btn-affirmative {
+
background: var(--primary);
+
color: white;
+
border-color: var(--primary);
+
}
+
+
.btn-affirmative:hover:not(:disabled) {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
.btn-neutral {
+
background: transparent;
+
color: var(--text);
+
border-color: var(--secondary);
+
}
+
+
.btn-neutral:hover:not(:disabled) {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
+
.btn-rejection {
+
background: transparent;
+
color: var(--accent);
+
border-color: var(--accent);
+
}
+
+
.btn-rejection:hover:not(:disabled) {
+
background: var(--accent);
+
color: white;
+
}
+
+
.modal-actions {
+
display: flex;
+
gap: 0.5rem;
+
margin-top: 1rem;
+
}
+
+
.user-menu {
+
position: absolute;
+
top: calc(100% + 0.5rem);
+
right: 0;
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
padding: 0.5rem;
+
min-width: 200px;
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+
display: flex;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.user-menu a,
+
.user-menu button {
+
padding: 0.75rem 1rem;
+
background: transparent;
+
color: var(--text);
+
text-decoration: none;
+
border: none;
+
border-radius: 6px;
+
font-weight: 500;
+
text-align: left;
+
transition: all 0.2s;
+
font-family: inherit;
+
font-size: 1rem;
+
cursor: pointer;
+
}
+
+
.user-menu a:hover,
+
.user-menu button:hover {
+
background: var(--secondary);
+
}
+
+
.loading {
+
font-size: 0.875rem;
+
color: var(--text);
+
}
+
+
.info-text {
+
color: var(--text);
+
font-size: 0.875rem;
+
margin: 0;
+
}
+
`;
+
+
override async connectedCallback() {
+
super.connectedCallback();
+
await this.checkAuth();
+
}
+
+
async checkAuth() {
+
try {
+
const response = await fetch("/api/auth/me");
+
+
if (response.ok) {
+
this.user = await response.json();
+
}
+
} finally {
+
this.loading = false;
+
}
+
}
+
+
private openModal() {
+
this.showModal = true;
+
this.needsRegistration = false;
+
this.email = "";
+
this.password = "";
+
this.name = "";
+
this.error = "";
+
}
+
+
private closeModal() {
+
this.showModal = false;
+
this.email = "";
+
this.password = "";
+
this.name = "";
+
this.error = "";
+
this.needsRegistration = false;
+
}
+
+
private async handleSubmit(e: Event) {
+
e.preventDefault();
+
this.error = "";
+
this.isSubmitting = true;
+
+
try {
+
if (this.needsRegistration) {
+
const response = await fetch("/api/auth/register", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
email: this.email,
+
password: this.password,
+
name: this.name,
+
}),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Registration failed";
+
return;
+
}
+
+
this.user = await response.json();
+
this.closeModal();
+
await this.checkAuth();
+
} else {
+
const response = await fetch("/api/auth/login", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
email: this.email,
+
password: this.password,
+
}),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
+
if (
+
response.status === 401 &&
+
data.error?.includes("Invalid email")
+
) {
+
this.needsRegistration = true;
+
this.error = "";
+
return;
+
}
+
+
this.error = data.error || "Login failed";
+
return;
+
}
+
+
this.user = await response.json();
+
this.closeModal();
+
await this.checkAuth();
+
}
+
} finally {
+
this.isSubmitting = false;
+
}
+
}
+
+
async handleLogout() {
+
try {
+
await fetch("/api/auth/logout", { method: "POST" });
+
this.user = null;
+
} catch {
+
// Silent fail
+
}
+
}
+
+
private toggleUserMenu() {
+
this.showModal = !this.showModal;
+
}
+
+
override render() {
+
if (this.loading) {
+
return html`<div class="loading">Loading...</div>`;
+
}
+
+
if (this.user) {
+
return html`
+
<div>
+
<button class="auth-button" @click=${this.toggleUserMenu}>
+
<img
+
src="https://hostedboringavatars.vercel.app/api/marble?size=24&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
+
alt="Avatar"
+
style="border-radius: 50%; width: 24px; height: 24px;"
+
/>
+
<span class="email">${this.user.name ?? this.user.email}</span>
+
<span>▼</span>
+
</button>
+
${
+
this.showModal
+
? html`
+
<div class="user-menu">
+
<a href="/settings" @click=${this.closeModal}>Settings</a>
+
<button @click=${this.handleLogout}>Logout</button>
+
</div>
+
`
+
: ""
+
}
+
</div>
+
`;
+
}
+
+
return html`
+
<div>
+
<button class="auth-button" @click=${this.openModal}>Login</button>
+
${
+
this.showModal
+
? html`
+
<div class="modal-overlay" @click=${this.closeModal}>
+
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
+
<h2>${this.needsRegistration ? "Complete Registration" : "Login"}</h2>
+
${
+
this.needsRegistration
+
? html`
+
<p class="info-text">
+
Welcome! We'll create an account for <strong>${this.email}</strong>
+
</p>
+
`
+
: ""
+
}
+
<form @submit=${this.handleSubmit}>
+
<div class="field">
+
<label for="email">Email</label>
+
<input
+
type="email"
+
id="email"
+
.value=${this.email}
+
@input=${(e: InputEvent) => {
+
this.email = (e.target as HTMLInputElement).value;
+
}}
+
required
+
?disabled=${this.needsRegistration}
+
/>
+
</div>
+
+
${
+
this.needsRegistration
+
? html`
+
<div class="field">
+
<label for="name">Name</label>
+
<input
+
type="text"
+
id="name"
+
.value=${this.name}
+
@input=${(e: InputEvent) => {
+
this.name = (
+
e.target as HTMLInputElement
+
).value;
+
}}
+
required
+
placeholder="What should we call you?"
+
/>
+
</div>
+
`
+
: ""
+
}
+
+
<div class="field">
+
<label for="password">${this.needsRegistration ? "Create Password" : "Password"}</label>
+
<input
+
type="password"
+
id="password"
+
.value=${this.password}
+
@input=${(e: InputEvent) => {
+
this.password = (e.target as HTMLInputElement).value;
+
}}
+
required
+
minlength="8"
+
placeholder=${this.needsRegistration ? "At least 8 characters plz" : ""}
+
/>
+
</div>
+
+
${this.error ? html`<p class="error">${this.error}</p>` : ""}
+
+
<div class="modal-actions">
+
<button
+
type="submit"
+
class="btn btn-affirmative"
+
?disabled=${this.isSubmitting}
+
>
+
${
+
this.isSubmitting
+
? "Loading..."
+
: this.needsRegistration
+
? "Create Account"
+
: "Login"
+
}
+
</button>
+
<button
+
type="button"
+
class="btn btn-neutral"
+
@click=${this.closeModal}
+
>
+
Cancel
+
</button>
+
</div>
+
</form>
+
</div>
+
</div>
+
`
+
: ""
+
}
+
</div>
+
`;
+
}
+
}
+1015
src/components/user-settings.ts
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, state } from "lit/decorators.js";
+
import { UAParser } from "ua-parser-js";
+
+
interface User {
+
email: string;
+
name: string | null;
+
avatar: string;
+
created_at: number;
+
}
+
+
interface Session {
+
id: string;
+
ip_address: string | null;
+
user_agent: string | null;
+
created_at: number;
+
expires_at: number;
+
is_current: boolean;
+
}
+
+
type SettingsPage = "account" | "sessions" | "danger";
+
+
@customElement("user-settings")
+
export class UserSettings extends LitElement {
+
@state() user: User | null = null;
+
@state() sessions: Session[] = [];
+
@state() loading = true;
+
@state() loadingSessions = true;
+
@state() error = "";
+
@state() showDeleteConfirm = false;
+
@state() currentPage: SettingsPage = "account";
+
@state() editingEmail = false;
+
@state() editingPassword = false;
+
@state() newEmail = "";
+
@state() newPassword = "";
+
@state() newName = "";
+
@state() newAvatar = "";
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
.settings-container {
+
display: flex;
+
gap: 3rem;
+
}
+
+
.sidebar {
+
width: 250px;
+
background: var(--background);
+
padding: 2rem 0;
+
display: flex;
+
flex-direction: column;
+
}
+
+
.sidebar-item {
+
padding: 0.75rem 1.5rem;
+
background: transparent;
+
color: var(--text);
+
border-radius: 6px;
+
border: 2px solid rgba(191, 192, 192, 0.3);
+
cursor: pointer;
+
font-family: inherit;
+
font-size: 1rem;
+
font-weight: 500;
+
text-align: left;
+
transition: all 0.2s;
+
margin: 0.25rem 1rem;
+
}
+
+
.sidebar-item:hover {
+
background: rgba(79, 93, 117, 0.1);
+
border-color: var(--secondary);
+
color: var(--primary);
+
}
+
+
.sidebar-item.active {
+
background: var(--primary);
+
color: white;
+
border-color: var(--primary);
+
}
+
+
.content {
+
flex: 1;
+
background: var(--background);
+
}
+
+
.content-inner {
+
max-width: 900px;
+
padding: 3rem 2rem 0rem 0;
+
}
+
+
.section {
+
background: var(--background);
+
border: 1px solid var(--secondary);
+
border-radius: 12px;
+
padding: 2rem;
+
margin-bottom: 2rem;
+
}
+
+
.section-title {
+
font-size: 1.25rem;
+
font-weight: 600;
+
color: var(--text);
+
margin: 0 0 1.5rem 0;
+
}
+
+
.field-group {
+
margin-bottom: 1.5rem;
+
}
+
+
.field-group:last-child {
+
margin-bottom: 0;
+
}
+
+
.field-row {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
gap: 1rem;
+
}
+
+
.field-label {
+
font-weight: 500;
+
color: var(--text);
+
font-size: 0.875rem;
+
margin-bottom: 0.5rem;
+
display: block;
+
}
+
+
.field-value {
+
font-size: 1rem;
+
color: var(--text);
+
opacity: 0.8;
+
}
+
+
.change-link {
+
background: none;
+
border: 1px solid var(--secondary);
+
color: var(--text);
+
font-size: 0.875rem;
+
font-weight: 500;
+
cursor: pointer;
+
padding: 0.25rem 0.75rem;
+
border-radius: 6px;
+
font-family: inherit;
+
transition: all 0.2s;
+
}
+
+
.change-link:hover {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
+
.btn {
+
padding: 0.75rem 1.5rem;
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
border: 2px solid transparent;
+
}
+
+
.btn-rejection {
+
background: transparent;
+
color: var(--accent);
+
border-color: var(--accent);
+
}
+
+
.btn-rejection:hover {
+
background: var(--accent);
+
color: white;
+
}
+
+
.btn-small {
+
padding: 0.5rem 1rem;
+
font-size: 0.875rem;
+
}
+
+
.avatar-container:hover .avatar-overlay {
+
opacity: 1;
+
}
+
+
.avatar-overlay {
+
position: absolute;
+
top: 0;
+
left: 0;
+
width: 48px;
+
height: 48px;
+
background: rgba(0, 0, 0, 0.2);
+
border-radius: 50%;
+
border: 2px solid transparent;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
opacity: 0;
+
cursor: pointer;
+
}
+
+
.reload-symbol {
+
font-size: 18px;
+
color: white;
+
transform: rotate(79deg) translate(0px, -2px);
+
}
+
+
.profile-row {
+
display: flex;
+
align-items: center;
+
gap: 1rem;
+
}
+
+
.avatar-container {
+
position: relative;
+
}
+
+
+
+
.danger-section {
+
border-color: var(--accent);
+
}
+
+
.danger-section .section-title {
+
color: var(--accent);
+
}
+
+
.danger-text {
+
color: var(--text);
+
opacity: 0.7;
+
margin-bottom: 1.5rem;
+
line-height: 1.5;
+
}
+
+
.session-list {
+
display: flex;
+
flex-direction: column;
+
gap: 1rem;
+
}
+
+
.session-card {
+
background: var(--background);
+
border: 1px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1.25rem;
+
}
+
+
.session-card.current {
+
border-color: var(--accent);
+
background: rgba(239, 131, 84, 0.03);
+
}
+
+
.session-header {
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
margin-bottom: 1rem;
+
}
+
+
.session-title {
+
font-weight: 600;
+
color: var(--text);
+
}
+
+
.current-badge {
+
display: inline-block;
+
background: var(--accent);
+
color: white;
+
padding: 0.25rem 0.5rem;
+
border-radius: 4px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
}
+
+
.session-details {
+
display: grid;
+
gap: 0.75rem;
+
}
+
+
.session-row {
+
display: grid;
+
grid-template-columns: 100px 1fr;
+
gap: 1rem;
+
}
+
+
.session-label {
+
font-weight: 500;
+
color: var(--text);
+
opacity: 0.6;
+
font-size: 0.875rem;
+
}
+
+
.session-value {
+
color: var(--text);
+
font-size: 0.875rem;
+
}
+
+
.user-agent {
+
font-family: monospace;
+
word-break: break-all;
+
}
+
+
.field-input {
+
padding: 0.5rem;
+
border: 1px solid var(--secondary);
+
border-radius: 6px;
+
font-family: inherit;
+
font-size: 1rem;
+
color: var(--text);
+
background: var(--background);
+
flex: 1;
+
}
+
+
.field-input:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.modal-overlay {
+
position: fixed;
+
top: 0;
+
left: 0;
+
right: 0;
+
bottom: 0;
+
background: rgba(0, 0, 0, 0.5);
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
z-index: 2000;
+
}
+
+
.modal {
+
background: var(--background);
+
border: 2px solid var(--accent);
+
border-radius: 12px;
+
padding: 2rem;
+
max-width: 400px;
+
width: 90%;
+
}
+
+
.modal h3 {
+
margin-top: 0;
+
color: var(--accent);
+
}
+
+
.modal-actions {
+
display: flex;
+
gap: 0.5rem;
+
margin-top: 1.5rem;
+
}
+
+
.btn-neutral {
+
background: transparent;
+
color: var(--text);
+
border-color: var(--secondary);
+
}
+
+
.btn-neutral:hover {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
+
.error {
+
color: var(--accent);
+
}
+
+
.loading {
+
text-align: center;
+
color: var(--text);
+
padding: 2rem;
+
}
+
+
@media (max-width: 768px) {
+
.settings-container {
+
flex-direction: column;
+
}
+
+
.sidebar {
+
width: 100%;
+
flex-direction: row;
+
overflow-x: auto;
+
padding: 1rem 0;
+
}
+
+
.sidebar-item {
+
white-space: nowrap;
+
border-left: none;
+
border-bottom: 3px solid transparent;
+
}
+
+
.sidebar-item.active {
+
border-left-color: transparent;
+
border-bottom-color: var(--accent);
+
}
+
+
.content-inner {
+
padding: 2rem 1rem;
+
}
+
}
+
`;
+
+
override async connectedCallback() {
+
super.connectedCallback();
+
await this.loadUser();
+
await this.loadSessions();
+
}
+
+
async loadUser() {
+
try {
+
const response = await fetch("/api/auth/me");
+
+
if (!response.ok) {
+
window.location.href = "/";
+
return;
+
}
+
+
this.user = await response.json();
+
} finally {
+
this.loading = false;
+
}
+
}
+
+
async loadSessions() {
+
try {
+
const response = await fetch("/api/sessions");
+
+
if (response.ok) {
+
const data = await response.json();
+
this.sessions = data.sessions;
+
}
+
} finally {
+
this.loadingSessions = false;
+
}
+
}
+
+
async handleLogout() {
+
try {
+
await fetch("/api/auth/logout", { method: "POST" });
+
window.location.href = "/";
+
} catch {
+
this.error = "Failed to logout";
+
}
+
}
+
+
async handleDeleteAccount() {
+
try {
+
const response = await fetch("/api/auth/delete-account", {
+
method: "DELETE",
+
});
+
+
if (!response.ok) {
+
this.error = "Failed to delete account";
+
return;
+
}
+
+
window.location.href = "/";
+
} catch {
+
this.error = "Failed to delete account";
+
} finally {
+
this.showDeleteConfirm = false;
+
}
+
}
+
+
async handleUpdateEmail() {
+
if (!this.newEmail) {
+
this.error = "Email required";
+
return;
+
}
+
+
try {
+
const response = await fetch("/api/user/email", {
+
method: "PUT",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ email: this.newEmail }),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to update email";
+
return;
+
}
+
+
// Reload user data
+
await this.loadUser();
+
this.editingEmail = false;
+
this.newEmail = "";
+
} catch {
+
this.error = "Failed to update email";
+
}
+
}
+
+
async handleUpdatePassword() {
+
if (!this.newPassword) {
+
this.error = "Password required";
+
return;
+
}
+
+
if (this.newPassword.length < 8) {
+
this.error = "Password must be at least 8 characters";
+
return;
+
}
+
+
try {
+
const response = await fetch("/api/user/password", {
+
method: "PUT",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ password: this.newPassword }),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to update password";
+
return;
+
}
+
+
this.editingPassword = false;
+
this.newPassword = "";
+
} catch {
+
this.error = "Failed to update password";
+
}
+
}
+
+
async handleUpdateName() {
+
if (!this.newName) {
+
this.error = "Name required";
+
return;
+
}
+
+
try {
+
const response = await fetch("/api/user/name", {
+
method: "PUT",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ name: this.newName }),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to update name";
+
return;
+
}
+
+
// Reload user data
+
await this.loadUser();
+
this.newName = "";
+
} catch {
+
this.error = "Failed to update name";
+
}
+
}
+
+
async handleUpdateAvatar() {
+
if (!this.newAvatar) {
+
this.error = "Avatar required";
+
return;
+
}
+
+
try {
+
const response = await fetch("/api/user/avatar", {
+
method: "PUT",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ avatar: this.newAvatar }),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to update avatar";
+
return;
+
}
+
+
// Reload user data
+
await this.loadUser();
+
this.newAvatar = "";
+
} catch {
+
this.error = "Failed to update avatar";
+
}
+
}
+
+
generateRandomAvatar() {
+
// Generate a random string for the avatar
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
+
let result = "";
+
for (let i = 0; i < 8; i++) {
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
+
}
+
this.newAvatar = result;
+
this.handleUpdateAvatar();
+
}
+
+
formatDate(timestamp: number, future = false): string {
+
const date = new Date(timestamp * 1000);
+
const now = new Date();
+
const diff = Math.abs(now.getTime() - date.getTime());
+
+
// For future dates (like expiration)
+
if (future || date > now) {
+
// Less than a day
+
if (diff < 24 * 60 * 60 * 1000) {
+
const hours = Math.floor(diff / (60 * 60 * 1000));
+
return `in ${hours} hour${hours === 1 ? "" : "s"}`;
+
}
+
+
// Less than a week
+
if (diff < 7 * 24 * 60 * 60 * 1000) {
+
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
+
return `in ${days} day${days === 1 ? "" : "s"}`;
+
}
+
+
// Show full date
+
return date.toLocaleDateString(undefined, {
+
month: "short",
+
day: "numeric",
+
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
+
});
+
}
+
+
// For past dates
+
// Less than a minute
+
if (diff < 60 * 1000) {
+
return "Just now";
+
}
+
+
// Less than an hour
+
if (diff < 60 * 60 * 1000) {
+
const minutes = Math.floor(diff / (60 * 1000));
+
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
+
}
+
+
// Less than a day
+
if (diff < 24 * 60 * 60 * 1000) {
+
const hours = Math.floor(diff / (60 * 60 * 1000));
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
+
}
+
+
// Less than a week
+
if (diff < 7 * 24 * 60 * 60 * 1000) {
+
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
+
return `${days} day${days === 1 ? "" : "s"} ago`;
+
}
+
+
// Show full date
+
return date.toLocaleDateString(undefined, {
+
month: "short",
+
day: "numeric",
+
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
+
});
+
}
+
+
async handleKillSession(sessionId: string) {
+
try {
+
const response = await fetch(`/api/sessions`, {
+
method: "DELETE",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ sessionId }),
+
});
+
+
if (!response.ok) {
+
this.error = "Failed to kill session";
+
return;
+
}
+
+
// Reload sessions
+
await this.loadSessions();
+
} catch {
+
this.error = "Failed to kill session";
+
}
+
}
+
+
parseUserAgent(userAgent: string | null): string {
+
if (!userAgent) return "Unknown";
+
+
const parser = new UAParser(userAgent);
+
const result = parser.getResult();
+
+
const browser = result.browser.name
+
? `${result.browser.name}${result.browser.version ? ` ${result.browser.version}` : ""}`
+
: "";
+
const os = result.os.name
+
? `${result.os.name}${result.os.version ? ` ${result.os.version}` : ""}`
+
: "";
+
+
if (browser && os) {
+
return `${browser} on ${os}`;
+
}
+
if (browser) return browser;
+
if (os) return os;
+
+
return userAgent;
+
}
+
+
renderAccountPage() {
+
if (!this.user) return html``;
+
+
const createdDate = new Date(
+
this.user.created_at * 1000,
+
).toLocaleDateString();
+
+
return html`
+
<div class="section">
+
<h2 class="section-title">Profile Information</h2>
+
+
<div class="field-group">
+
<div class="profile-row">
+
<div class="avatar-container">
+
<img
+
src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
+
alt="Avatar"
+
style="border-radius: 50%; width: 48px; height: 48px; border: 2px solid var(--secondary); cursor: pointer;"
+
@click=${this.generateRandomAvatar}
+
/>
+
<div class="avatar-overlay" @click=${this.generateRandomAvatar}>
+
<span class="reload-symbol">↻</span>
+
</div>
+
</div>
+
<input
+
type="text"
+
class="field-input"
+
style="flex: 1;"
+
.value=${this.user.name ?? ""}
+
@input=${(e: Event) => {
+
this.newName = (e.target as HTMLInputElement).value;
+
}}
+
@blur=${() => {
+
if (this.newName && this.newName !== (this.user?.name ?? "")) {
+
this.handleUpdateName();
+
}
+
}}
+
placeholder="Your name"
+
/>
+
</div>
+
</div>
+
+
<div class="field-group">
+
<label class="field-label">Email</label>
+
${
+
this.editingEmail
+
? html`
+
<div style="display: flex; gap: 0.5rem; align-items: center;">
+
<input
+
type="email"
+
class="field-input"
+
.value=${this.newEmail}
+
@input=${(e: Event) => {
+
this.newEmail = (e.target as HTMLInputElement).value;
+
}}
+
placeholder=${this.user.email}
+
/>
+
<button
+
class="btn btn-affirmative btn-small"
+
@click=${this.handleUpdateEmail}
+
>
+
Save
+
</button>
+
<button
+
class="btn btn-neutral btn-small"
+
@click=${() => {
+
this.editingEmail = false;
+
this.newEmail = "";
+
}}
+
>
+
Cancel
+
</button>
+
</div>
+
`
+
: html`
+
<div class="field-row">
+
<div class="field-value">${this.user.email}</div>
+
<button
+
class="change-link"
+
@click=${() => {
+
this.editingEmail = true;
+
this.newEmail = this.user?.email ?? "";
+
}}
+
>
+
Change
+
</button>
+
</div>
+
`
+
}
+
</div>
+
+
<div class="field-group">
+
<label class="field-label">Password</label>
+
${
+
this.editingPassword
+
? html`
+
<div style="display: flex; gap: 0.5rem; align-items: center;">
+
<input
+
type="password"
+
class="field-input"
+
.value=${this.newPassword}
+
@input=${(e: Event) => {
+
this.newPassword = (e.target as HTMLInputElement).value;
+
}}
+
placeholder="New password"
+
/>
+
<button
+
class="btn btn-affirmative btn-small"
+
@click=${this.handleUpdatePassword}
+
>
+
Save
+
</button>
+
<button
+
class="btn btn-neutral btn-small"
+
@click=${() => {
+
this.editingPassword = false;
+
this.newPassword = "";
+
}}
+
>
+
Cancel
+
</button>
+
</div>
+
`
+
: html`
+
<div class="field-row">
+
<div class="field-value">••••••••</div>
+
<button
+
class="change-link"
+
@click=${() => {
+
this.editingPassword = true;
+
}}
+
>
+
Change
+
</button>
+
</div>
+
`
+
}
+
</div>
+
+
<div class="field-group">
+
<label class="field-label">Member Since</label>
+
<div class="field-value">${createdDate}</div>
+
</div>
+
</div>
+
+
`;
+
}
+
+
renderSessionsPage() {
+
return html`
+
<div class="section">
+
<h2 class="section-title">Active Sessions</h2>
+
${
+
this.loadingSessions
+
? html`<div class="loading">Loading sessions...</div>`
+
: this.sessions.length === 0
+
? html`<p>No active sessions</p>`
+
: html`
+
<div class="session-list">
+
${this.sessions.map(
+
(session) => html`
+
<div class="session-card ${session.is_current ? "current" : ""}">
+
<div class="session-header">
+
<span class="session-title">Session</span>
+
${session.is_current ? html`<span class="current-badge">Current</span>` : ""}
+
</div>
+
<div class="session-details">
+
<div class="session-row">
+
<span class="session-label">IP Address</span>
+
<span class="session-value">${session.ip_address ?? "Unknown"}</span>
+
</div>
+
<div class="session-row">
+
<span class="session-label">Device</span>
+
<span class="session-value">${this.parseUserAgent(session.user_agent)}</span>
+
</div>
+
<div class="session-row">
+
<span class="session-label">Created</span>
+
<span class="session-value">${this.formatDate(session.created_at)}</span>
+
</div>
+
<div class="session-row">
+
<span class="session-label">Expires</span>
+
<span class="session-value">${this.formatDate(session.expires_at, true)}</span>
+
</div>
+
</div>
+
<div style="margin-top: 1rem;">
+
${
+
session.is_current
+
? html`
+
<button
+
class="btn btn-rejection"
+
@click=${this.handleLogout}
+
>
+
Logout
+
</button>
+
`
+
: html`
+
<button
+
class="btn btn-rejection"
+
@click=${() => this.handleKillSession(session.id)}
+
>
+
Kill Session
+
</button>
+
`
+
}
+
</div>
+
</div>
+
`,
+
)}
+
</div>
+
`
+
}
+
</div>
+
`;
+
}
+
+
renderDangerPage() {
+
return html`
+
<div class="section danger-section">
+
<h2 class="section-title">Delete Account</h2>
+
<p class="danger-text">
+
Once you delete your account, there is no going back. This will
+
permanently delete your account and all associated data.
+
</p>
+
<button
+
class="btn btn-rejection"
+
@click=${() => {
+
this.showDeleteConfirm = true;
+
}}
+
>
+
Delete Account
+
</button>
+
</div>
+
`;
+
}
+
+
override render() {
+
if (this.loading) {
+
return html`<div class="loading">Loading...</div>`;
+
}
+
+
if (this.error) {
+
return html`<div class="error">${this.error}</div>`;
+
}
+
+
if (!this.user) {
+
return html`<div class="error">No user data available</div>`;
+
}
+
+
return html`
+
<div class="settings-container">
+
<div class="sidebar">
+
<button
+
class="sidebar-item ${this.currentPage === "account" ? "active" : ""}"
+
@click=${() => {
+
this.currentPage = "account";
+
}}
+
>
+
Account
+
</button>
+
<button
+
class="sidebar-item ${this.currentPage === "sessions" ? "active" : ""}"
+
@click=${() => {
+
this.currentPage = "sessions";
+
}}
+
>
+
Sessions
+
</button>
+
<button
+
class="sidebar-item ${this.currentPage === "danger" ? "active" : ""}"
+
@click=${() => {
+
this.currentPage = "danger";
+
}}
+
>
+
Danger Zone
+
</button>
+
</div>
+
+
<div class="content">
+
<div class="content-inner">
+
${
+
this.currentPage === "account"
+
? this.renderAccountPage()
+
: this.currentPage === "sessions"
+
? this.renderSessionsPage()
+
: this.renderDangerPage()
+
}
+
</div>
+
</div>
+
</div>
+
+
${
+
this.showDeleteConfirm
+
? html`
+
<div
+
class="modal-overlay"
+
@click=${() => {
+
this.showDeleteConfirm = false;
+
}}
+
>
+
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
+
<h3>Delete Account</h3>
+
<p>
+
Are you absolutely sure? This action cannot be undone. All your data will be
+
permanently deleted.
+
</p>
+
<div class="modal-actions">
+
<button class="btn btn-rejection" @click=${this.handleDeleteAccount}>
+
Yes, Delete My Account
+
</button>
+
<button
+
class="btn btn-neutral"
+
@click=${() => {
+
this.showDeleteConfirm = false;
+
}}
+
>
+
Cancel
+
</button>
+
</div>
+
</div>
+
</div>
+
`
+
: ""
+
}
+
`;
+
}
+
}
+86
src/db/schema.ts
···
+
import { Database } from "bun:sqlite";
+
+
export const db = new Database("thistle.db");
+
+
// Schema version tracking
+
db.run(`
+
CREATE TABLE IF NOT EXISTS schema_migrations (
+
version INTEGER PRIMARY KEY,
+
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
)
+
`);
+
+
const migrations = [
+
{
+
version: 1,
+
name: "Complete schema",
+
sql: `
+
CREATE TABLE IF NOT EXISTS users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT NOT NULL,
+
name TEXT,
+
avatar TEXT DEFAULT 'd',
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
);
+
+
CREATE TABLE IF NOT EXISTS sessions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
ip_address TEXT,
+
user_agent TEXT,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
expires_at INTEGER NOT NULL,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
+
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
+
`,
+
},
+
];
+
+
function getCurrentVersion(): number {
+
const result = db
+
.query<{ version: number }, []>(
+
"SELECT MAX(version) as version FROM schema_migrations",
+
)
+
.get();
+
return result?.version ?? 0;
+
}
+
+
function applyMigration(
+
version: number,
+
sql: string,
+
index: number,
+
total: number,
+
) {
+
const current = getCurrentVersion();
+
if (current >= version) return;
+
+
const isTTY = typeof process !== "undefined" && process.stdout?.isTTY;
+
const startMsg = `Applying migration ${index + 1} of ${total}`;
+
+
if (isTTY) {
+
process.stdout.write(`${startMsg}...`);
+
const start = performance.now();
+
db.run(sql);
+
db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]);
+
const duration = Math.round(performance.now() - start);
+
process.stdout.write(`\r${startMsg} (${duration}ms)\n`);
+
} else {
+
console.log(startMsg);
+
db.run(sql);
+
db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]);
+
}
+
}
+
+
// Apply all migrations
+
const current = getCurrentVersion();
+
const pending = migrations.filter((m) => m.version > current);
+
+
for (const [index, migration] of pending.entries()) {
+
applyMigration(migration.version, migration.sql, index, pending.length);
+
}
+
+
export default db;
+367
src/index.ts
···
+
import {
+
authenticateUser,
+
cleanupExpiredSessions,
+
createSession,
+
createUser,
+
deleteSession,
+
deleteUser,
+
getSession,
+
getSessionFromRequest,
+
getUserBySession,
+
getUserSessionsForUser,
+
updateUserAvatar,
+
updateUserEmail,
+
updateUserName,
+
updateUserPassword,
+
} from "./lib/auth";
import indexHTML from "./pages/index.html";
+
import settingsHTML from "./pages/settings.html";
+
+
// Clean up expired sessions every hour
+
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
const server = Bun.serve({
port: 3000,
routes: {
"/": indexHTML,
+
"/settings": settingsHTML,
+
"/api/auth/register": {
+
POST: async (req) => {
+
try {
+
const body = await req.json();
+
const { email, password, name } = body;
+
+
if (!email || !password) {
+
return Response.json(
+
{ error: "Email and password required" },
+
{ status: 400 },
+
);
+
}
+
+
if (password.length < 8) {
+
return Response.json(
+
{ error: "Password must be at least 8 characters" },
+
{ status: 400 },
+
);
+
}
+
+
const user = await createUser(email, password, name);
+
const ipAddress =
+
req.headers.get("x-forwarded-for") ??
+
req.headers.get("x-real-ip") ??
+
"unknown";
+
const userAgent = req.headers.get("user-agent") ?? "unknown";
+
const sessionId = createSession(user.id, ipAddress, userAgent);
+
+
return Response.json(
+
{ user: { id: user.id, email: user.email } },
+
{
+
headers: {
+
"Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
+
},
+
},
+
);
+
} catch (err: unknown) {
+
const error = err as { message?: string };
+
if (error.message?.includes("UNIQUE constraint failed")) {
+
return Response.json(
+
{ error: "Email already registered" },
+
{ status: 400 },
+
);
+
}
+
return Response.json(
+
{ error: "Registration failed" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
+
"/api/auth/login": {
+
POST: async (req) => {
+
try {
+
const body = await req.json();
+
const { email, password } = body;
+
+
if (!email || !password) {
+
return Response.json(
+
{ error: "Email and password required" },
+
{ status: 400 },
+
);
+
}
+
+
const user = await authenticateUser(email, password);
+
+
if (!user) {
+
return Response.json(
+
{ error: "Invalid email or password" },
+
{ status: 401 },
+
);
+
}
+
+
const ipAddress =
+
req.headers.get("x-forwarded-for") ??
+
req.headers.get("x-real-ip") ??
+
"unknown";
+
const userAgent = req.headers.get("user-agent") ?? "unknown";
+
const sessionId = createSession(user.id, ipAddress, userAgent);
+
+
return Response.json(
+
{ user: { id: user.id, email: user.email } },
+
{
+
headers: {
+
"Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
+
},
+
},
+
);
+
} catch (_) {
+
return Response.json({ error: "Login failed" }, { status: 500 });
+
}
+
},
+
},
+
"/api/auth/logout": {
+
POST: (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (sessionId) {
+
deleteSession(sessionId);
+
}
+
+
return Response.json(
+
{ success: true },
+
{
+
headers: {
+
"Set-Cookie":
+
"session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
+
},
+
},
+
);
+
},
+
},
+
"/api/auth/me": {
+
GET: (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
return Response.json({
+
email: user.email,
+
name: user.name,
+
avatar: user.avatar,
+
created_at: user.created_at,
+
});
+
},
+
},
+
"/api/sessions": {
+
GET: (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
const sessions = getUserSessionsForUser(user.id);
+
return Response.json({
+
sessions: sessions.map((s) => ({
+
id: s.id,
+
ip_address: s.ip_address,
+
user_agent: s.user_agent,
+
created_at: s.created_at,
+
expires_at: s.expires_at,
+
is_current: s.id === sessionId,
+
})),
+
});
+
},
+
DELETE: async (req) => {
+
const currentSessionId = getSessionFromRequest(req);
+
if (!currentSessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
+
const user = getUserBySession(currentSessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
const body = await req.json();
+
const targetSessionId = body.sessionId;
+
+
if (!targetSessionId) {
+
return Response.json(
+
{ error: "Session ID required" },
+
{ status: 400 },
+
);
+
}
+
+
// Verify the session belongs to the user
+
const targetSession = getSession(targetSessionId);
+
if (!targetSession || targetSession.user_id !== user.id) {
+
return Response.json({ error: "Session not found" }, { status: 404 });
+
}
+
+
deleteSession(targetSessionId);
+
+
return Response.json({ success: true });
+
},
+
},
+
"/api/auth/delete-account": {
+
DELETE: (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
deleteUser(user.id);
+
+
return Response.json(
+
{ success: true },
+
{
+
headers: {
+
"Set-Cookie":
+
"session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
+
},
+
},
+
);
+
},
+
},
+
"/api/user/email": {
+
PUT: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
const body = await req.json();
+
const { email } = body;
+
+
if (!email) {
+
return Response.json({ error: "Email required" }, { status: 400 });
+
}
+
+
try {
+
updateUserEmail(user.id, email);
+
return Response.json({ success: true });
+
} catch (err: unknown) {
+
const error = err as { message?: string };
+
if (error.message?.includes("UNIQUE constraint failed")) {
+
return Response.json(
+
{ error: "Email already in use" },
+
{ status: 400 },
+
);
+
}
+
return Response.json(
+
{ error: "Failed to update email" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
+
"/api/user/password": {
+
PUT: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
const body = await req.json();
+
const { password } = body;
+
+
if (!password) {
+
return Response.json({ error: "Password required" }, { status: 400 });
+
}
+
+
if (password.length < 8) {
+
return Response.json(
+
{ error: "Password must be at least 8 characters" },
+
{ status: 400 },
+
);
+
}
+
+
try {
+
await updateUserPassword(user.id, password);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update password" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
+
"/api/user/name": {
+
PUT: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
const body = await req.json();
+
const { name } = body;
+
+
if (!name) {
+
return Response.json({ error: "Name required" }, { status: 400 });
+
}
+
+
try {
+
updateUserName(user.id, name);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update name" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
+
"/api/user/avatar": {
+
PUT: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
+
const body = await req.json();
+
const { avatar } = body;
+
+
if (!avatar) {
+
return Response.json({ error: "Avatar required" }, { status: 400 });
+
}
+
+
try {
+
updateUserAvatar(user.id, avatar);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update avatar" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
},
development: {
hmr: true,
+176
src/lib/auth.ts
···
+
import db from "../db/schema";
+
+
const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
+
+
export interface User {
+
id: number;
+
email: string;
+
name: string | null;
+
avatar: string;
+
created_at: number;
+
}
+
+
export interface Session {
+
id: string;
+
user_id: number;
+
ip_address: string | null;
+
user_agent: string | null;
+
created_at: number;
+
expires_at: number;
+
}
+
+
export async function hashPassword(password: string): Promise<string> {
+
return await Bun.password.hash(password, {
+
algorithm: "argon2id",
+
memoryCost: 19456,
+
timeCost: 2,
+
});
+
}
+
+
export async function verifyPassword(
+
password: string,
+
hash: string,
+
): Promise<boolean> {
+
return await Bun.password.verify(password, hash, "argon2id");
+
}
+
+
export function createSession(
+
userId: number,
+
ipAddress?: string,
+
userAgent?: string,
+
): string {
+
const sessionId = crypto.randomUUID();
+
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
+
+
db.run(
+
"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",
+
[sessionId, userId, ipAddress ?? null, userAgent ?? null, expiresAt],
+
);
+
+
return sessionId;
+
}
+
+
export function getSession(sessionId: string): Session | null {
+
const now = Math.floor(Date.now() / 1000);
+
+
const session = db
+
.query<Session, [string, number]>(
+
"SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE id = ? AND expires_at > ?",
+
)
+
.get(sessionId, now);
+
+
return session ?? null;
+
}
+
+
export function getUserBySession(sessionId: string): User | null {
+
const session = getSession(sessionId);
+
if (!session) return null;
+
+
const user = db
+
.query<User, [number]>(
+
"SELECT id, email, name, avatar, created_at FROM users WHERE id = ?",
+
)
+
.get(session.user_id);
+
+
return user ?? null;
+
}
+
+
export function deleteSession(sessionId: string): void {
+
db.run("DELETE FROM sessions WHERE id = ?", [sessionId]);
+
}
+
+
export function cleanupExpiredSessions(): void {
+
const now = Math.floor(Date.now() / 1000);
+
db.run("DELETE FROM sessions WHERE expires_at <= ?", [now]);
+
}
+
+
export async function createUser(
+
email: string,
+
password: string,
+
name?: string,
+
): Promise<User> {
+
const passwordHash = await hashPassword(password);
+
+
const result = db.run(
+
"INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)",
+
[email, passwordHash, name ?? null],
+
);
+
+
const user = db
+
.query<User, [number]>("SELECT id, email, name, avatar, created_at FROM users WHERE id = ?")
+
.get(Number(result.lastInsertRowid));
+
+
if (!user) {
+
throw new Error("Failed to create user");
+
}
+
+
return user;
+
}
+
+
export async function authenticateUser(
+
email: string,
+
password: string,
+
): Promise<User | null> {
+
const result = db
+
.query<{ id: number; email: string; name: string | null; password_hash: string; created_at: number }, [string]>(
+
"SELECT id, email, name, avatar, password_hash, created_at FROM users WHERE email = ?",
+
)
+
.get(email);
+
+
if (!result) return null;
+
+
const isValid = await verifyPassword(password, result.password_hash);
+
if (!isValid) return null;
+
+
return {
+
id: result.id,
+
email: result.email,
+
name: result.name,
+
avatar: result.avatar,
+
created_at: result.created_at,
+
};
+
}
+
+
export function getUserSessionsForUser(userId: number): Session[] {
+
const now = Math.floor(Date.now() / 1000);
+
+
const sessions = db
+
.query<Session, [number, number]>(
+
"SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC",
+
)
+
.all(userId, now);
+
+
return sessions;
+
}
+
+
export function getSessionFromRequest(req: Request): string | null {
+
const cookie = req.headers.get("cookie");
+
if (!cookie) return null;
+
+
const match = cookie.match(/session=([^;]+)/);
+
return match?.[1] ?? null;
+
}
+
+
export function deleteUser(userId: number): void {
+
db.run("DELETE FROM users WHERE id = ?", [userId]);
+
}
+
+
export function updateUserEmail(userId: number, newEmail: string): void {
+
db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
+
}
+
+
export function updateUserName(userId: number, newName: string): void {
+
db.run("UPDATE users SET name = ? WHERE id = ?", [newName, userId]);
+
}
+
+
export function updateUserAvatar(userId: number, avatar: string): void {
+
db.run("UPDATE users SET avatar = ? WHERE id = ?", [avatar, userId]);
+
}
+
+
export async function updateUserPassword(
+
userId: number,
+
newPassword: string,
+
): Promise<void> {
+
const hash = await hashPassword(newPassword);
+
db.run("UPDATE users SET password_hash = ? WHERE id = ?", [hash, userId]);
+
}
+9
src/pages/index.html
···
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
<link rel="stylesheet" href="../styles/main.css">
+
<style>
+
main {
+
max-width: 48rem;
+
}
+
</style>
</head>
<body>
+
<auth-component></auth-component>
+
<main>
<h1>Thistle</h1>
+
<p>Here is a basic counter to figure out the basics of web components</p>
<counter-component></counter-component>
</main>
<script type="module" src="../components/counter.ts"></script>
+
<script type="module" src="../components/auth.ts"></script>
</body>
</html>
+25
src/pages/settings.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Settings - Thistle</title>
+
<link rel="icon"
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
+
<link rel="stylesheet" href="../styles/main.css">
+
</head>
+
+
<body>
+
<auth-component></auth-component>
+
+
<main>
+
<h1>Settings</h1>
+
<user-settings></user-settings>
+
</main>
+
+
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="../components/user-settings.ts"></script>
+
</body>
+
+
</html>
+59
src/styles/buttons.css
···
+
/* Shared button styles for consistent UI across components */
+
+
.btn {
+
padding: 0.75rem 1.5rem;
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
border: 2px solid;
+
}
+
+
.btn:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
/* Affirmative actions (submit, save, confirm) */
+
.btn-affirmative {
+
background: var(--primary);
+
color: white;
+
border-color: var(--primary);
+
}
+
+
.btn-affirmative:hover:not(:disabled) {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
/* Neutral actions (cancel, close) */
+
.btn-neutral {
+
background: transparent;
+
color: var(--text);
+
border-color: var(--secondary);
+
}
+
+
.btn-neutral:hover:not(:disabled) {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
+
/* Rejection/destructive actions (delete, logout) */
+
.btn-rejection {
+
background: transparent;
+
color: var(--accent);
+
border-color: var(--accent);
+
}
+
+
.btn-rejection:hover:not(:disabled) {
+
background: var(--accent);
+
color: white;
+
}
+
+
/* Small button variant */
+
.btn-small {
+
padding: 0.5rem 1rem;
+
font-size: 0.875rem;
+
}
+48 -15
src/styles/main.css
···
+
@import url('./buttons.css');
+
:root {
-
--text: #5b6971;
-
--background: #fefbf1;
-
--primary: #8fa668;
-
--secondary: #d0cdf9;
-
--accent: #e59976;
+
/* Color palette */
+
--gunmetal: #2d3142ff;
+
--paynes-gray: #4f5d75ff;
+
--silver: #bfc0c0ff;
+
--off-white: #fcf6f1;
+
--coral: #ef8354ff;
+
+
/* Semantic color assignments */
+
--text: var(--gunmetal);
+
--background: var(--off-white);
+
--primary: var(--paynes-gray);
+
--secondary: var(--silver);
+
--accent: var(--coral);
}
body {
···
color: var(--text);
}
-
h1, h2, h3, h4, h5 {
+
h1,
+
h2,
+
h3,
+
h4,
+
h5 {
font-family: 'Charter', 'Bitstream Charter', 'Sitka Text', Cambria, serif;
font-weight: 600;
line-height: 1.2;
color: var(--text);
}
-
html {font-size: 100%;} /* 16px */
+
html {
+
font-size: 100%;
+
}
+
+
/* 16px */
h1 {
-
font-size: 4.210rem; /* 67.36px */
+
font-size: 4.210rem;
+
/* 67.36px */
margin-top: 0;
}
-
h2 {font-size: 3.158rem; /* 50.56px */}
+
h2 {
+
font-size: 3.158rem;
+
/* 50.56px */
+
}
-
h3 {font-size: 2.369rem; /* 37.92px */}
+
h3 {
+
font-size: 2.369rem;
+
/* 37.92px */
+
}
-
h4 {font-size: 1.777rem; /* 28.48px */}
+
h4 {
+
font-size: 1.777rem;
+
/* 28.48px */
+
}
-
h5 {font-size: 1.333rem; /* 21.28px */}
+
h5 {
+
font-size: 1.333rem;
+
/* 21.28px */
+
}
-
small {font-size: 0.750rem; /* 12px */}
+
small {
+
font-size: 0.750rem;
+
/* 12px */
+
}
main {
-
max-width: 800px;
margin: 0 auto;
-
}
+
}