init
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# ── Postgres ────────────────────────────────────────────────
|
||||
POSTGRES_USER=shoplist
|
||||
POSTGRES_PASSWORD=change_me_in_prod
|
||||
POSTGRES_DB=shoplist
|
||||
|
||||
# ── Backend ─────────────────────────────────────────────────
|
||||
# DATABASE_URL is built from the values above inside docker-compose.
|
||||
DATABASE_URL=postgres://shoplist:change_me_in_prod@localhost:5432/shoplist
|
||||
BACKEND_HOST=0.0.0.0
|
||||
BACKEND_PORT=8080
|
||||
# 64+ hex chars. Generate: openssl rand -hex 32
|
||||
SESSION_SECRET=please_generate_a_long_random_secret_at_least_64_chars_xxxxxxxxxx
|
||||
# Public URL of the frontend, used to build verification/reset links.
|
||||
PUBLIC_APP_URL=http://localhost:5173
|
||||
# Comma-separated allowed CORS origins.
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# ── SMTP (mail notifications + verification) ────────────────
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=postmaster@example.com
|
||||
SMTP_PASSWORD=change_me
|
||||
SMTP_FROM="Shopping List <no-reply@example.com>"
|
||||
# starttls | tls | none (none = dev only)
|
||||
SMTP_SECURITY=starttls
|
||||
|
||||
# ── Frontend ────────────────────────────────────────────────
|
||||
# Where the SvelteKit app reaches the backend (server-side).
|
||||
PUBLIC_API_BASE=http://localhost:8080
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Rust
|
||||
/backend/target
|
||||
**/*.rs.bk
|
||||
|
||||
# Node / SvelteKit
|
||||
node_modules
|
||||
/frontend/.svelte-kit
|
||||
/frontend/build
|
||||
/frontend/.vercel
|
||||
|
||||
# Env / secrets
|
||||
.env
|
||||
*.local
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea
|
||||
@@ -0,0 +1,55 @@
|
||||
# shopping-list
|
||||
|
||||
Self-hosted, topic-based shopping lists with price-drop notifications.
|
||||
Track things you want to buy (clothes, gear, whatever), paste product URLs,
|
||||
get mailed when a deal appears.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend** — Rust, [Axum](https://github.com/tokio-rs/axum), SQLx + Postgres,
|
||||
argon2 password hashing, server-side sessions (tower-sessions), lettre SMTP.
|
||||
- **Frontend** — SvelteKit + TailwindCSS, grunge/breakcore theme.
|
||||
- **Infra** — docker-compose (Postgres + backend + frontend).
|
||||
|
||||
## Status
|
||||
|
||||
Phased build. Current: **Phase 1 — auth foundation**.
|
||||
|
||||
| Phase | Scope |
|
||||
|-------|-------|
|
||||
| 1 | Accounts, email verification, login/logout, password reset, per-user settings |
|
||||
| 2 | Lists (topics) + items CRUD |
|
||||
| 3 | Tracked URLs: paste → periodic refetch → price parse + history |
|
||||
| 4 | Notifications: SMTP price-drop alerts |
|
||||
| 5 | Design polish |
|
||||
|
||||
## Quick start (dev)
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# edit .env — set SESSION_SECRET (openssl rand -hex 32) and SMTP creds
|
||||
|
||||
# Postgres only, run backend/frontend natively while developing:
|
||||
docker compose up -d db
|
||||
|
||||
# backend
|
||||
cd backend && cargo run
|
||||
|
||||
# frontend (separate shell)
|
||||
cd frontend && pnpm install && pnpm dev
|
||||
```
|
||||
|
||||
Full stack in containers:
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
App: http://localhost:5173 · API: http://localhost:8080
|
||||
|
||||
## Notes
|
||||
|
||||
- Deal tracking uses **user-pasted product URLs** refetched on a schedule —
|
||||
no hardcoded retailer scrapers. Region default: DE/EU, EUR.
|
||||
- SMTP is required for email verification + notifications. Use a real provider
|
||||
or a dev catcher like [Mailpit](https://github.com/axllent/mailpit).
|
||||
Generated
+2907
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "shoplist-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
tower-sessions = "0.14"
|
||||
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
||||
|
||||
sqlx = { version = "0.8", default-features = false, features = [
|
||||
"runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "macros",
|
||||
] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
lettre = { version = "0.11", default-features = false, features = [
|
||||
"tokio1-rustls-tls", "smtp-transport", "builder", "hostname",
|
||||
] }
|
||||
|
||||
validator = { version = "0.19", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
time = { version = "0.3", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
dotenvy = "0.15"
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Phase 1: auth foundation
|
||||
-- Requires Postgres. citext for case-insensitive email; pgcrypto for gen_random_uuid.
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email CITEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One row per user; created on registration.
|
||||
CREATE TABLE user_settings (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
locale TEXT NOT NULL DEFAULT 'de', -- 'de' | 'en'
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
theme TEXT NOT NULL DEFAULT 'breakcore',
|
||||
notify_email BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Email verification + password reset tokens.
|
||||
-- Only the SHA-256 hash of the token is stored; raw token lives in the emailed link.
|
||||
CREATE TYPE token_purpose AS ENUM ('verify_email', 'password_reset');
|
||||
|
||||
CREATE TABLE auth_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash BYTEA NOT NULL UNIQUE,
|
||||
purpose token_purpose NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_auth_tokens_user ON auth_tokens(user_id);
|
||||
CREATE INDEX idx_auth_tokens_expires ON auth_tokens(expires_at);
|
||||
|
||||
-- Touch updated_at on row changes.
|
||||
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_users_updated
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_user_settings_updated
|
||||
BEFORE UPDATE ON user_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod password;
|
||||
pub mod routes;
|
||||
pub mod session;
|
||||
pub mod tokens;
|
||||
@@ -0,0 +1,23 @@
|
||||
use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Hash a plaintext password with Argon2id + random salt (PHC string).
|
||||
pub fn hash_password(plain: &str) -> AppResult<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(plain.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("password hash failed: {e}")))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a stored PHC hash.
|
||||
/// Returns Ok(false) on mismatch, error only on malformed stored hash.
|
||||
pub fn verify_password(plain: &str, stored_hash: &str) -> AppResult<bool> {
|
||||
let parsed = PasswordHash::new(stored_hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("stored hash malformed: {e}")))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::Duration;
|
||||
use tower_sessions::Session;
|
||||
use validator::Validate;
|
||||
|
||||
use super::password::{hash_password, verify_password};
|
||||
use super::session::{set_user, AuthUser};
|
||||
use super::tokens::{self, TokenPurpose};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{User, UserPublic, UserSettings};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/verify", post(verify_email))
|
||||
.route("/resend-verification", post(resend_verification))
|
||||
.route("/request-password-reset", post(request_password_reset))
|
||||
.route("/reset-password", post(reset_password))
|
||||
.route("/me", axum::routing::get(me))
|
||||
}
|
||||
|
||||
// ── Request payloads ────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct RegisterReq {
|
||||
#[validate(email(message = "invalid email"))]
|
||||
email: String,
|
||||
#[validate(length(min = 10, max = 256, message = "password must be 10-256 chars"))]
|
||||
password: String,
|
||||
#[validate(length(max = 80, message = "display name too long"))]
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginReq {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenReq {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct EmailReq {
|
||||
#[validate(email)]
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct ResetReq {
|
||||
token: String,
|
||||
#[validate(length(min = 10, max = 256, message = "password must be 10-256 chars"))]
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
// ── Response payloads ───────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MeResp {
|
||||
user: UserPublic,
|
||||
settings: UserSettings,
|
||||
}
|
||||
|
||||
fn validate<T: Validate>(payload: &T) -> AppResult<()> {
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))
|
||||
}
|
||||
|
||||
/// Detect a Postgres unique-violation (SQLSTATE 23505).
|
||||
fn is_unique_violation(e: &sqlx::Error) -> bool {
|
||||
matches!(e, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505"))
|
||||
}
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────
|
||||
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Json(req): Json<RegisterReq>,
|
||||
) -> AppResult<(StatusCode, Json<UserPublic>)> {
|
||||
validate(&req)?;
|
||||
|
||||
let hash = hash_password(&req.password)?;
|
||||
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, password_hash, display_name)
|
||||
VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(&req.email)
|
||||
.bind(&hash)
|
||||
.bind(display)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_unique_violation(&e) {
|
||||
AppError::Conflict("email already registered".into())
|
||||
} else {
|
||||
e.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
// Default settings row.
|
||||
sqlx::query("INSERT INTO user_settings (user_id) VALUES ($1)")
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Email delivery failure must not abort signup; user can resend later.
|
||||
if let Err(e) = send_verification_email(&state, &user).await {
|
||||
tracing::warn!(user_id = %user.id, error = %e, "failed to send verification email");
|
||||
}
|
||||
|
||||
// Log the new user in immediately; features can gate on email_verified.
|
||||
set_user(&session, user.id).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(UserPublic::from(&user))))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Json(req): Json<LoginReq>,
|
||||
) -> AppResult<Json<UserPublic>> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||
.bind(&req.email)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Constant-ish: only reveal a single generic error for unknown user or bad password.
|
||||
let user = match user {
|
||||
Some(u) if verify_password(&req.password, &u.password_hash)? => u,
|
||||
_ => return Err(AppError::BadRequest("invalid email or password".into())),
|
||||
};
|
||||
|
||||
set_user(&session, user.id).await?;
|
||||
Ok(Json(UserPublic::from(&user)))
|
||||
}
|
||||
|
||||
async fn logout(session: Session) -> AppResult<StatusCode> {
|
||||
session
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("session flush: {e}")))?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn verify_email(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<TokenReq>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let user_id = tokens::consume(&state.pool, &req.token, TokenPurpose::VerifyEmail).await?;
|
||||
sqlx::query("UPDATE users SET email_verified = TRUE WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn resend_verification(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> AppResult<StatusCode> {
|
||||
if user.email_verified {
|
||||
return Err(AppError::BadRequest("email already verified".into()));
|
||||
}
|
||||
send_verification_email(&state, &user).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn request_password_reset(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<EmailReq>,
|
||||
) -> AppResult<StatusCode> {
|
||||
validate(&req)?;
|
||||
|
||||
// Always return 204 regardless of account existence (no enumeration).
|
||||
if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||
.bind(&req.email)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
{
|
||||
let token =
|
||||
tokens::create(&state.pool, user.id, TokenPurpose::PasswordReset, Duration::hours(1))
|
||||
.await?;
|
||||
let link = format!("{}/reset?token={}", state.config.public_app_url, token);
|
||||
let _ = state
|
||||
.mailer
|
||||
.send(
|
||||
&user.email,
|
||||
"Reset your password",
|
||||
&format!("Reset your password:\n{link}\n\nThis link expires in 1 hour."),
|
||||
&format!(
|
||||
"<p>Reset your password:</p><p><a href=\"{link}\">{link}</a></p>\
|
||||
<p>This link expires in 1 hour.</p>"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn reset_password(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ResetReq>,
|
||||
) -> AppResult<StatusCode> {
|
||||
validate(&req)?;
|
||||
let user_id = tokens::consume(&state.pool, &req.token, TokenPurpose::PasswordReset).await?;
|
||||
let hash = hash_password(&req.new_password)?;
|
||||
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||
.bind(&hash)
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn me(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> AppResult<Json<MeResp>> {
|
||||
let settings = sqlx::query_as::<_, UserSettings>(
|
||||
"SELECT user_id, locale, currency, theme, notify_email
|
||||
FROM user_settings WHERE user_id = $1",
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MeResp {
|
||||
user: UserPublic::from(&user),
|
||||
settings,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
async fn send_verification_email(state: &AppState, user: &User) -> AppResult<()> {
|
||||
let token =
|
||||
tokens::create(&state.pool, user.id, TokenPurpose::VerifyEmail, Duration::hours(24)).await?;
|
||||
let link = format!("{}/verify?token={}", state.config.public_app_url, token);
|
||||
state
|
||||
.mailer
|
||||
.send(
|
||||
&user.email,
|
||||
"Verify your email",
|
||||
&format!("Welcome! Verify your email:\n{link}\n\nThis link expires in 24 hours."),
|
||||
&format!(
|
||||
"<p>Welcome! Verify your email:</p><p><a href=\"{link}\">{link}</a></p>\
|
||||
<p>This link expires in 24 hours.</p>"
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use sqlx::PgPool;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::User;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Session key holding the authenticated user's id.
|
||||
pub const USER_ID_KEY: &str = "user_id";
|
||||
|
||||
/// Store the user id in the session (call after a successful login/verify).
|
||||
pub async fn set_user(session: &Session, user_id: Uuid) -> AppResult<()> {
|
||||
// Rotate the session id on privilege change to prevent fixation.
|
||||
session
|
||||
.cycle_id()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("session cycle: {e}")))?;
|
||||
session
|
||||
.insert(USER_ID_KEY, user_id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("session insert: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a user by id, if present.
|
||||
pub async fn load_user(pool: &PgPool, id: Uuid) -> AppResult<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Extractor that requires an authenticated user; rejects with 401 otherwise.
|
||||
pub struct AuthUser(pub User);
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let session = parts
|
||||
.extensions
|
||||
.get::<Session>()
|
||||
.cloned()
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let id = session
|
||||
.get::<Uuid>(USER_ID_KEY)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("session read: {e}")))?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let user = load_user(&state.pool, id)
|
||||
.await?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
Ok(AuthUser(user))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Purpose of a one-time auth token. Maps to the Postgres `token_purpose` enum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||
#[sqlx(type_name = "token_purpose", rename_all = "snake_case")]
|
||||
pub enum TokenPurpose {
|
||||
VerifyEmail,
|
||||
PasswordReset,
|
||||
}
|
||||
|
||||
/// Generate a URL-safe random token (raw, only ever emailed to the user).
|
||||
fn generate_raw() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// SHA-256 of the raw token; this is what we persist.
|
||||
fn hash_token(raw: &str) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(raw.as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Create and store a token, returning the raw value to embed in an email link.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
purpose: TokenPurpose,
|
||||
ttl: Duration,
|
||||
) -> AppResult<String> {
|
||||
let raw = generate_raw();
|
||||
let hash = hash_token(&raw);
|
||||
let expires_at = OffsetDateTime::now_utc() + ttl;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO auth_tokens (user_id, token_hash, purpose, expires_at)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(hash)
|
||||
.bind(purpose)
|
||||
.bind(expires_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Consume a token: validate hash+purpose, ensure unexpired and unconsumed,
|
||||
/// mark consumed, and return the owning user id. Atomic via single UPDATE.
|
||||
pub async fn consume(pool: &PgPool, raw: &str, purpose: TokenPurpose) -> AppResult<Uuid> {
|
||||
let hash = hash_token(raw);
|
||||
|
||||
let user_id: Option<Uuid> = sqlx::query_scalar(
|
||||
"UPDATE auth_tokens
|
||||
SET consumed_at = now()
|
||||
WHERE token_hash = $1
|
||||
AND purpose = $2
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at > now()
|
||||
RETURNING user_id",
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(purpose)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
user_id.ok_or(AppError::BadRequest(
|
||||
"invalid or expired token".to_string(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use std::env;
|
||||
|
||||
/// Runtime configuration, loaded from environment variables.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub session_secret: String,
|
||||
pub public_app_url: String,
|
||||
pub cors_origins: Vec<String>,
|
||||
pub smtp: SmtpConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SmtpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub from: String,
|
||||
pub security: SmtpSecurity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SmtpSecurity {
|
||||
StartTls,
|
||||
Tls,
|
||||
None,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
// Load .env if present; ignore if missing (e.g. in containers).
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let session_secret = req("SESSION_SECRET")?;
|
||||
if session_secret.len() < 32 {
|
||||
anyhow::bail!("SESSION_SECRET must be at least 32 chars");
|
||||
}
|
||||
|
||||
let cors_origins = opt("CORS_ORIGINS", "http://localhost:5173")
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let security = match opt("SMTP_SECURITY", "starttls").to_lowercase().as_str() {
|
||||
"tls" => SmtpSecurity::Tls,
|
||||
"none" => SmtpSecurity::None,
|
||||
_ => SmtpSecurity::StartTls,
|
||||
};
|
||||
|
||||
Ok(Config {
|
||||
database_url: req("DATABASE_URL")?,
|
||||
host: opt("BACKEND_HOST", "0.0.0.0"),
|
||||
port: opt("BACKEND_PORT", "8080").parse()?,
|
||||
session_secret,
|
||||
public_app_url: opt("PUBLIC_APP_URL", "http://localhost:5173"),
|
||||
cors_origins,
|
||||
smtp: SmtpConfig {
|
||||
host: opt("SMTP_HOST", "localhost"),
|
||||
port: opt("SMTP_PORT", "587").parse()?,
|
||||
username: opt("SMTP_USERNAME", ""),
|
||||
password: opt("SMTP_PASSWORD", ""),
|
||||
from: opt("SMTP_FROM", "Shopping List <no-reply@localhost>"),
|
||||
security,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn req(key: &str) -> anyhow::Result<String> {
|
||||
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var: {key}"))
|
||||
}
|
||||
|
||||
fn opt(key: &str, default: &str) -> String {
|
||||
env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Build the Postgres pool and run migrations.
|
||||
pub async fn connect(database_url: &str) -> anyhow::Result<PgPool> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.acquire_timeout(Duration::from_secs(5))
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
/// Application-wide error type. Maps to an HTTP status + JSON body `{ "error": ... }`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("authentication required")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// Anything unexpected. Logged in full; client sees a generic message.
|
||||
#[error("internal error")]
|
||||
Internal(#[source] anyhow::Error),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
AppError::Forbidden => StatusCode::FORBIDDEN,
|
||||
AppError::NotFound => StatusCode::NOT_FOUND,
|
||||
AppError::Conflict(_) => StatusCode::CONFLICT,
|
||||
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
if let AppError::Internal(ref e) = self {
|
||||
tracing::error!(error = ?e, "internal error");
|
||||
}
|
||||
let status = self.status();
|
||||
let body = Json(json!({ "error": self.to_string() }));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
// Convert common error sources into a 500 by default.
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
AppError::Internal(e.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
AppError::Internal(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart};
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
use crate::config::{SmtpConfig, SmtpSecurity};
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Wraps an async SMTP transport plus the configured `From` address.
|
||||
#[derive(Clone)]
|
||||
pub struct Mailer {
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: Mailbox,
|
||||
}
|
||||
|
||||
impl Mailer {
|
||||
pub fn from_config(cfg: &SmtpConfig) -> anyhow::Result<Self> {
|
||||
let mut builder = match cfg.security {
|
||||
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)?,
|
||||
SmtpSecurity::StartTls => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)?
|
||||
}
|
||||
SmtpSecurity::None => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
|
||||
}
|
||||
}
|
||||
.port(cfg.port);
|
||||
|
||||
if !cfg.username.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(
|
||||
cfg.username.clone(),
|
||||
cfg.password.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
let from: Mailbox = cfg
|
||||
.from
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid SMTP_FROM: {e}"))?;
|
||||
|
||||
Ok(Mailer {
|
||||
transport: builder.build(),
|
||||
from,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a multipart (plain + HTML) email.
|
||||
pub async fn send(&self, to: &str, subject: &str, text: &str, html: &str) -> AppResult<()> {
|
||||
let to: Mailbox = to
|
||||
.parse()
|
||||
.map_err(|_| AppError::BadRequest("invalid recipient address".into()))?;
|
||||
|
||||
let msg = Message::builder()
|
||||
.from(self.from.clone())
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.multipart(
|
||||
MultiPart::alternative()
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(text.to_string()),
|
||||
)
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(ContentType::TEXT_HTML)
|
||||
.body(html.to_string()),
|
||||
),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("build email: {e}")))?;
|
||||
|
||||
self.transport
|
||||
.send(msg)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("send email: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
mod mail;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::{header, HeaderValue, Method};
|
||||
use axum::Router;
|
||||
use time::Duration;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tower_sessions::{Expiry, SessionManagerLayer};
|
||||
use tower_sessions_sqlx_store::PostgresStore;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
use config::Config;
|
||||
use mail::Mailer;
|
||||
use state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
tracing::info!(port = config.port, "starting shoplist-backend");
|
||||
|
||||
let pool = db::connect(&config.database_url).await?;
|
||||
let mailer = Mailer::from_config(&config.smtp)?;
|
||||
|
||||
// Session store (separate table set, managed by the store's own migrations).
|
||||
let session_store = PostgresStore::new(pool.clone());
|
||||
session_store.migrate().await?;
|
||||
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false) // set true behind HTTPS in production
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::days(30)));
|
||||
|
||||
let cors = build_cors(&config.cors_origins)?;
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
config: Arc::new(config.clone()),
|
||||
mailer,
|
||||
};
|
||||
|
||||
let api = Router::new()
|
||||
.merge(routes::router())
|
||||
.nest("/auth", auth::routes::router());
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", api)
|
||||
.layer(cors)
|
||||
.layer(session_layer)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!("listening on {addr}");
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_cors(origins: &[String]) -> anyhow::Result<CorsLayer> {
|
||||
let parsed: Vec<HeaderValue> = origins
|
||||
.iter()
|
||||
.map(|o| o.parse::<HeaderValue>())
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| anyhow::anyhow!("invalid CORS origin: {e}"))?;
|
||||
|
||||
Ok(CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(parsed))
|
||||
.allow_credentials(true)
|
||||
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
|
||||
.allow_headers([header::CONTENT_TYPE]))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Full user row. `password_hash` never leaves the backend.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// Safe representation sent to clients.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserPublic {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub display_name: Option<String>,
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
impl From<&User> for UserPublic {
|
||||
fn from(u: &User) -> Self {
|
||||
UserPublic {
|
||||
id: u.id,
|
||||
email: u.email.clone(),
|
||||
display_name: u.display_name.clone(),
|
||||
email_verified: u.email_verified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct UserSettings {
|
||||
#[serde(skip)]
|
||||
pub user_id: Uuid,
|
||||
pub locale: String,
|
||||
pub currency: String,
|
||||
pub theme: String,
|
||||
pub notify_email: bool,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use axum::extract::State;
|
||||
use axum::routing::{get, patch};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::auth::session::AuthUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::UserSettings;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/settings", patch(update_settings))
|
||||
.route("/profile", patch(update_profile))
|
||||
}
|
||||
|
||||
async fn health() -> Json<Value> {
|
||||
Json(json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
const ALLOWED_LOCALES: &[&str] = &["de", "en"];
|
||||
const ALLOWED_THEMES: &[&str] = &["breakcore", "grunge", "minimal"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SettingsReq {
|
||||
locale: Option<String>,
|
||||
#[serde(default)]
|
||||
currency: Option<String>,
|
||||
theme: Option<String>,
|
||||
notify_email: Option<bool>,
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<SettingsReq>,
|
||||
) -> AppResult<Json<UserSettings>> {
|
||||
if let Some(loc) = &req.locale {
|
||||
if !ALLOWED_LOCALES.contains(&loc.as_str()) {
|
||||
return Err(AppError::Validation(format!("unsupported locale: {loc}")));
|
||||
}
|
||||
}
|
||||
if let Some(theme) = &req.theme {
|
||||
if !ALLOWED_THEMES.contains(&theme.as_str()) {
|
||||
return Err(AppError::Validation(format!("unsupported theme: {theme}")));
|
||||
}
|
||||
}
|
||||
if let Some(cur) = &req.currency {
|
||||
if cur.len() != 3 {
|
||||
return Err(AppError::Validation("currency must be a 3-letter code".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let settings = sqlx::query_as::<_, UserSettings>(
|
||||
"UPDATE user_settings SET
|
||||
locale = COALESCE($2, locale),
|
||||
currency = COALESCE($3, currency),
|
||||
theme = COALESCE($4, theme),
|
||||
notify_email = COALESCE($5, notify_email)
|
||||
WHERE user_id = $1
|
||||
RETURNING user_id, locale, currency, theme, notify_email",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(req.locale)
|
||||
.bind(req.currency.map(|c| c.to_uppercase()))
|
||||
.bind(req.theme)
|
||||
.bind(req.notify_email)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct ProfileReq {
|
||||
#[validate(length(max = 80, message = "display name too long"))]
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<ProfileReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
sqlx::query("UPDATE users SET display_name = $2 WHERE id = $1")
|
||||
.bind(user.id)
|
||||
.bind(display)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "display_name": display })))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::mail::Mailer;
|
||||
|
||||
/// Shared application state injected into every handler.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Arc<Config>,
|
||||
pub mailer: Mailer,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Dev-only SMTP catcher. UI at http://localhost:8025, SMTP on :1025.
|
||||
# Point SMTP_HOST=mailpit SMTP_PORT=1025 SMTP_SECURITY=none to use it.
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
BACKEND_HOST: 0.0.0.0
|
||||
BACKEND_PORT: 8080
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS}
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM: ${SMTP_FROM}
|
||||
SMTP_SECURITY: ${SMTP_SECURITY}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
PUBLIC_API_BASE: ${PUBLIC_API_BASE}
|
||||
ports:
|
||||
- "5173:3000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=false
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "shoplist-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"svelte": "^5.15.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+1534
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
@@ -0,0 +1,252 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Web fonts loaded via <link> in app.html (terminal + brutalist mono vibe). */
|
||||
|
||||
/* ── Design tokens ─────────────────────────────────────────── */
|
||||
@theme {
|
||||
--color-void: #0a0a0b;
|
||||
--color-ash: #131316;
|
||||
--color-panel: #17171b;
|
||||
--color-smoke: #2a2a30;
|
||||
--color-ink: #e9e7e1;
|
||||
--color-mute: #8a8a93;
|
||||
|
||||
--color-acid: #c2f73f; /* toxic green */
|
||||
--color-blood: #ff1f6b; /* hot magenta */
|
||||
--color-cyber: #28e0e0; /* cyan */
|
||||
--color-bruise: #7a3cff; /* electric purple */
|
||||
|
||||
--font-display: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-mono: 'Space Mono', ui-monospace, monospace;
|
||||
--font-term: 'VT323', monospace;
|
||||
|
||||
--radius-none: 0px;
|
||||
}
|
||||
|
||||
/* ── Base ──────────────────────────────────────────────────── */
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: var(--color-void);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Film grain + scanlines layered over everything. */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.05;
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.6) 0px,
|
||||
rgba(255, 255, 255, 0.6) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
opacity: 0.4;
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, rgba(122, 60, 255, 0.12), transparent 40%),
|
||||
radial-gradient(circle at 85% 80%, rgba(255, 31, 107, 0.1), transparent 45%);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-acid);
|
||||
color: var(--color-void);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-cyber);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-acid);
|
||||
}
|
||||
|
||||
/* Brutalist scrollbar. */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-void);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-smoke);
|
||||
border: 1px solid var(--color-blood);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Components ────────────────────────────────────────────── */
|
||||
@layer components {
|
||||
/* Hard-edged panel with offset shadow — xerox/zine look. */
|
||||
.panel {
|
||||
background: var(--color-panel);
|
||||
border: 2px solid var(--color-smoke);
|
||||
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-blood);
|
||||
}
|
||||
|
||||
.panel-acid {
|
||||
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-acid);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-mute);
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
background: var(--color-void);
|
||||
border: 2px solid var(--color-smoke);
|
||||
color: var(--color-ink);
|
||||
padding: 0.7rem 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
transition: border-color 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.field::placeholder {
|
||||
color: #55555e;
|
||||
}
|
||||
.field:focus {
|
||||
border-color: var(--color-acid);
|
||||
box-shadow: 0 0 0 1px var(--color-acid), 0 0 18px -6px var(--color-acid);
|
||||
}
|
||||
|
||||
/* Chunky brutalist button. */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border: 2px solid var(--color-ink);
|
||||
background: var(--color-ink);
|
||||
color: var(--color-void);
|
||||
cursor: pointer;
|
||||
transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.1s;
|
||||
box-shadow: 4px 4px 0 0 var(--color-blood);
|
||||
}
|
||||
.btn:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 6px 6px 0 0 var(--color-blood);
|
||||
}
|
||||
.btn:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 1px 1px 0 0 var(--color-blood);
|
||||
}
|
||||
.btn-acid {
|
||||
background: var(--color-acid);
|
||||
border-color: var(--color-acid);
|
||||
box-shadow: 4px 4px 0 0 var(--color-void);
|
||||
}
|
||||
.btn-acid:hover {
|
||||
box-shadow: 6px 6px 0 0 var(--color-void);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-ink);
|
||||
box-shadow: 4px 4px 0 0 var(--color-smoke);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
/* Glitchy duplicated-layer heading. */
|
||||
.glitch {
|
||||
position: relative;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.glitch::before,
|
||||
.glitch::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.glitch::before {
|
||||
color: var(--color-blood);
|
||||
transform: translate(-2px, 0);
|
||||
mix-blend-mode: screen;
|
||||
clip-path: inset(0 0 55% 0);
|
||||
animation: glitch-x 3.5s infinite steps(2);
|
||||
}
|
||||
.glitch::after {
|
||||
color: var(--color-cyber);
|
||||
transform: translate(2px, 0);
|
||||
mix-blend-mode: screen;
|
||||
clip-path: inset(55% 0 0 0);
|
||||
animation: glitch-x 2.7s infinite steps(2) reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-x {
|
||||
0%, 92%, 100% { transform: translate(0, 0); }
|
||||
93% { transform: translate(-3px, 1px); }
|
||||
96% { transform: translate(3px, -1px); }
|
||||
}
|
||||
|
||||
.marquee {
|
||||
white-space: nowrap;
|
||||
animation: marquee 22s linear infinite;
|
||||
}
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.flicker {
|
||||
animation: flicker 4s infinite;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
97% { opacity: 1; }
|
||||
98% { opacity: 0.4; }
|
||||
99% { opacity: 0.9; }
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Space+Mono:ital,wght@0,400;0,700;1,400&family=VT323&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div class="contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const BASE = env.PUBLIC_API_BASE || 'http://localhost:8080';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) },
|
||||
...opts
|
||||
});
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, data?.error ?? res.statusText);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(p: string) => request<T>(p),
|
||||
post: <T>(p: string, body?: unknown) =>
|
||||
request<T>(p, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(p: string, body?: unknown) =>
|
||||
request<T>(p, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined })
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { api } from './api';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
email_verified: boolean;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
locale: string;
|
||||
currency: string;
|
||||
theme: string;
|
||||
notify_email: boolean;
|
||||
};
|
||||
|
||||
type Me = { user: User; settings: Settings };
|
||||
|
||||
class AuthStore {
|
||||
user = $state<User | null>(null);
|
||||
settings = $state<Settings | null>(null);
|
||||
loaded = $state(false);
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const me = await api.get<Me>('/auth/me');
|
||||
this.user = me.user;
|
||||
this.settings = me.settings;
|
||||
} catch {
|
||||
this.user = null;
|
||||
this.settings = null;
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
set(me: Me) {
|
||||
this.user = me.user;
|
||||
this.settings = me.settings;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
this.user = null;
|
||||
this.settings = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
auth.refresh();
|
||||
});
|
||||
|
||||
async function doLogout() {
|
||||
await auth.logout();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
const ticker =
|
||||
'BUY LESS · WANT MORE · TRACK THE DROP · NO IMPULSE · GRAB THE DEAL · ';
|
||||
</script>
|
||||
|
||||
<div class="min-h-dvh flex flex-col">
|
||||
<!-- Ticker strip -->
|
||||
<div class="bg-acid text-void overflow-hidden border-b-2 border-void">
|
||||
<div class="marquee py-1 font-mono text-xs font-bold tracking-widest">
|
||||
{ticker.repeat(6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="border-b-2 border-smoke">
|
||||
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
||||
<a href="/" class="group flex items-baseline gap-2">
|
||||
<span
|
||||
class="glitch flicker font-display text-2xl font-bold tracking-tighter text-ink"
|
||||
data-text="//WANTLIST"
|
||||
>
|
||||
//WANTLIST
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
{#if auth.loaded && auth.user}
|
||||
<a href="/settings" class="tag border-smoke text-mute hover:text-acid">
|
||||
{auth.user.display_name ?? auth.user.email}
|
||||
</a>
|
||||
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={doLogout}>
|
||||
logout
|
||||
</button>
|
||||
{:else if auth.loaded}
|
||||
{#if page.url.pathname !== '/login'}
|
||||
<a href="/login" class="tag border-smoke text-mute hover:text-acid">login</a>
|
||||
{/if}
|
||||
{#if page.url.pathname !== '/register'}
|
||||
<a href="/register" class="btn btn-acid !px-3 !py-1 text-xs">sign up</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Unverified banner -->
|
||||
{#if auth.loaded && auth.user && !auth.user.email_verified}
|
||||
<div class="border-b-2 border-blood bg-blood/10 px-4 py-2 text-center text-xs text-blood">
|
||||
⚠ email not verified — check your inbox. lost it?
|
||||
<a href="/settings" class="underline">resend from settings</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-10">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="border-t-2 border-smoke px-4 py-6 text-center">
|
||||
<p class="label">self-hosted · rust + sveltekit · phase 1</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>//WANTLIST</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if auth.loaded && auth.user}
|
||||
<!-- Logged-in placeholder dashboard -->
|
||||
<section class="space-y-6">
|
||||
<div>
|
||||
<p class="label">logged in as</p>
|
||||
<h1 class="font-display text-3xl font-bold">
|
||||
{auth.user.display_name ?? auth.user.email}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-acid p-6">
|
||||
<p class="tag mb-3 inline-block border-acid text-acid">phase 2</p>
|
||||
<h2 class="mb-2 text-xl font-bold">your lists land here</h2>
|
||||
<p class="max-w-prose text-mute">
|
||||
topic-based wantlists (clothes, gear, whatever), item tracking, and pasted
|
||||
product URLs that get refetched for price drops. building it next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#each [['LISTS', 'soon'], ['TRACKED URLS', 'soon'], ['DEAL ALERTS', 'soon']] as [k, v]}
|
||||
<div class="panel p-4">
|
||||
<p class="label">{k}</p>
|
||||
<p class="font-display text-2xl text-blood">{v}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<!-- Marketing hero -->
|
||||
<section class="space-y-10">
|
||||
<div class="space-y-4">
|
||||
<p class="tag inline-block border-cyber text-cyber">self-hosted · rust core</p>
|
||||
<h1
|
||||
class="glitch font-display text-5xl font-bold leading-[0.95] sm:text-7xl"
|
||||
data-text="TRACK WHAT YOU WANT. STRIKE ON THE DROP."
|
||||
>
|
||||
TRACK WHAT YOU WANT. STRIKE ON THE DROP.
|
||||
</h1>
|
||||
<p class="max-w-xl text-lg text-mute">
|
||||
Topic-based shopping lists for the things you actually want. Paste a product
|
||||
URL, and get mailed the moment the price tanks. No feed. No algorithm. Your
|
||||
server, your rules.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/register" class="btn btn-acid">make an account →</a>
|
||||
<a href="/login" class="btn btn-ghost">log in</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#each [['01', 'LIST IT', 'group wants by topic'], ['02', 'PASTE URL', 'we watch the price'], ['03', 'GET MAILED', 'strike on the drop']] as [n, t, d]}
|
||||
<div class="panel p-5">
|
||||
<p class="font-term text-4xl text-blood">{n}</p>
|
||||
<p class="mt-1 font-display text-lg font-bold">{t}</p>
|
||||
<p class="text-sm text-mute">{d}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { api, ApiError } from '$lib/api';
|
||||
|
||||
let email = $state('');
|
||||
let done = $state(false);
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/auth/request-password-reset', { email });
|
||||
done = true;
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'something broke';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>reset · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel p-8">
|
||||
<p class="label">password reset</p>
|
||||
<h1 class="mb-6 font-display text-3xl font-bold">LOST THE KEY</h1>
|
||||
|
||||
{#if done}
|
||||
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid">
|
||||
if that email exists, a reset link is on its way. check your inbox.
|
||||
</p>
|
||||
{:else}
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<label class="label" for="em">email</label>
|
||||
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
<button class="btn w-full" disabled={busy}>{busy ? 'sending…' : 'send reset link'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<p class="mt-5 text-center text-sm text-mute"><a href="/login">back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/auth/login', { email, password });
|
||||
await auth.refresh();
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'something broke';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>log in · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel p-8">
|
||||
<p class="label">welcome back</p>
|
||||
<h1 class="mb-6 font-display text-3xl font-bold">RE-ENTER THE PIT</h1>
|
||||
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<label class="label" for="em">email</label>
|
||||
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="pw">password</label>
|
||||
<input id="pw" class="field mt-1" type="password" bind:value={password} required autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button class="btn w-full" disabled={busy}>{busy ? 'entering…' : 'log in'}</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-5 flex justify-between text-sm text-mute">
|
||||
<a href="/register">need an account?</a>
|
||||
<a href="/forgot">forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let displayName = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/auth/register', {
|
||||
email,
|
||||
password,
|
||||
display_name: displayName || null
|
||||
});
|
||||
await auth.refresh();
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'something broke';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>sign up · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel panel-acid p-8">
|
||||
<p class="label">new account</p>
|
||||
<h1 class="mb-6 font-display text-3xl font-bold">CARVE YOUR MARK</h1>
|
||||
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<label class="label" for="dn">display name <span class="text-mute">(optional)</span></label>
|
||||
<input id="dn" class="field mt-1" bind:value={displayName} maxlength="80" autocomplete="nickname" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="em">email</label>
|
||||
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="pw">password <span class="text-mute">(min 10)</span></label>
|
||||
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-acid w-full" disabled={busy}>
|
||||
{busy ? 'carving…' : 'sign up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-5 text-center text-sm text-mute">
|
||||
already have one? <a href="/login">log in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
|
||||
const token = $derived(page.url.searchParams.get('token') ?? '');
|
||||
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
let done = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/auth/reset-password', { token, new_password: password });
|
||||
done = true;
|
||||
setTimeout(() => goto('/login'), 1500);
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'something broke';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>new password · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel panel-acid p-8">
|
||||
<p class="label">set new password</p>
|
||||
<h1 class="mb-6 font-display text-3xl font-bold">CUT A NEW KEY</h1>
|
||||
|
||||
{#if !token}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-3 text-sm text-blood">
|
||||
no reset token in this link. request a fresh one.
|
||||
</p>
|
||||
<p class="mt-5 text-center text-sm text-mute"><a href="/forgot">request reset</a></p>
|
||||
{:else if done}
|
||||
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid">
|
||||
password changed. redirecting to login…
|
||||
</p>
|
||||
{:else}
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<label class="label" for="pw">new password <span class="text-mute">(min 10)</span></label>
|
||||
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" />
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
<button class="btn btn-acid w-full" disabled={busy}>{busy ? 'cutting…' : 'set password'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { auth, type Settings } from '$lib/auth.svelte';
|
||||
|
||||
let displayName = $state('');
|
||||
let settings = $state<Settings>({ locale: 'de', currency: 'EUR', theme: 'breakcore', notify_email: true });
|
||||
|
||||
let msg = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
let resendMsg = $state('');
|
||||
|
||||
// Sync local form state once auth finishes loading.
|
||||
$effect(() => {
|
||||
if (auth.loaded && !auth.user) {
|
||||
goto('/login');
|
||||
}
|
||||
if (auth.user) {
|
||||
displayName = auth.user.display_name ?? '';
|
||||
}
|
||||
if (auth.settings) {
|
||||
settings = { ...auth.settings };
|
||||
}
|
||||
});
|
||||
|
||||
const themes = ['breakcore', 'grunge', 'minimal'];
|
||||
const locales = ['de', 'en'];
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
msg = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.patch('/profile', { display_name: displayName || null });
|
||||
await api.patch('/settings', settings);
|
||||
await auth.refresh();
|
||||
msg = 'saved.';
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'save failed';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
resendMsg = '';
|
||||
try {
|
||||
await api.post('/auth/resend-verification');
|
||||
resendMsg = 'sent — check your inbox.';
|
||||
} catch (err) {
|
||||
resendMsg = err instanceof ApiError ? err.message : 'failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>settings · //WANTLIST</title></svelte:head>
|
||||
|
||||
{#if auth.loaded && auth.user}
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<p class="label">configuration</p>
|
||||
<h1 class="font-display text-4xl font-bold">CONTROL PANEL</h1>
|
||||
</div>
|
||||
|
||||
<!-- Verification status -->
|
||||
<div class="panel p-5">
|
||||
<p class="label">email</p>
|
||||
<div class="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="font-mono">{auth.user.email}</span>
|
||||
{#if auth.user.email_verified}
|
||||
<span class="tag border-acid text-acid">verified</span>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="tag border-blood text-blood">unverified</span>
|
||||
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={resend}>resend</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if resendMsg}<p class="mt-2 text-xs text-mute">{resendMsg}</p>{/if}
|
||||
</div>
|
||||
|
||||
<form class="panel panel-acid space-y-6 p-6" onsubmit={save}>
|
||||
<div>
|
||||
<label class="label" for="dn">display name</label>
|
||||
<input id="dn" class="field mt-1" bind:value={displayName} maxlength="80" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="label" for="loc">language</label>
|
||||
<select id="loc" class="field mt-1" bind:value={settings.locale}>
|
||||
{#each locales as l}<option value={l}>{l.toUpperCase()}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="cur">currency</label>
|
||||
<input id="cur" class="field mt-1 uppercase" bind:value={settings.currency} maxlength="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="label mb-2">theme</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each themes as t}
|
||||
<button
|
||||
type="button"
|
||||
class="tag border-smoke py-2 text-center transition-colors"
|
||||
class:!border-acid={settings.theme === t}
|
||||
class:text-acid={settings.theme === t}
|
||||
onclick={() => (settings.theme = t)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input type="checkbox" class="size-5 accent-acid" bind:checked={settings.notify_email} />
|
||||
<span class="font-mono text-sm">email me about deals & price drops</span>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
{#if msg}
|
||||
<p class="border-2 border-acid bg-acid/10 px-3 py-2 text-sm text-acid">{msg}</p>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-acid" disabled={busy}>{busy ? 'saving…' : 'save changes'}</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-mute flicker">loading…</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
type State = 'working' | 'ok' | 'error';
|
||||
let status = $state<State>('working');
|
||||
let message = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
const token = page.url.searchParams.get('token');
|
||||
if (!token) {
|
||||
status = 'error';
|
||||
message = 'no verification token in this link.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/auth/verify', { token });
|
||||
await auth.refresh();
|
||||
status = 'ok';
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
message = err instanceof ApiError ? err.message : 'verification failed';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>verify · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel p-8 text-center">
|
||||
<p class="label">email verification</p>
|
||||
|
||||
{#if status === 'working'}
|
||||
<h1 class="mt-4 font-display text-3xl font-bold flicker">VERIFYING…</h1>
|
||||
{:else if status === 'ok'}
|
||||
<h1 class="mt-4 font-display text-3xl font-bold text-acid">VERIFIED ✓</h1>
|
||||
<p class="mt-3 text-mute">your email is confirmed. you're all set.</p>
|
||||
<a href="/" class="btn btn-acid mt-6">enter →</a>
|
||||
{:else}
|
||||
<h1 class="mt-4 font-display text-3xl font-bold text-blood">FAILED ✗</h1>
|
||||
<p class="mt-3 text-mute">{message}</p>
|
||||
<a href="/" class="btn btn-ghost mt-6">go home</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#0a0a0b"/>
|
||||
<rect x="6" y="6" width="20" height="20" fill="none" stroke="#c2f73f" stroke-width="2"/>
|
||||
<path d="M10 16 l4 4 l8 -9" fill="none" stroke="#ff1f6b" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user