adding api
This commit is contained in:
parent
5a7cfd8f9a
commit
88d7738409
15 changed files with 4197 additions and 150 deletions
5
api/.gitignore
vendored
Normal file
5
api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Generated build artifacts
|
||||
/target
|
||||
|
||||
# Rust backup files
|
||||
**/*.rs.bk
|
||||
3066
api/Cargo.lock
generated
Normal file
3066
api/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
api/Cargo.toml
Normal file
17
api/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
bcrypt = "0.15"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
jsonwebtoken = "9"
|
||||
rocket = { version = "0.5.0", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio", "postgres", "chrono", "macros", "migrate"] }
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
1
api/migrations/20250101000000_enable_pg_trgm.sql
Normal file
1
api/migrations/20250101000000_enable_pg_trgm.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
6
api/migrations/20250101000500_create_users.sql
Normal file
6
api/migrations/20250101000500_create_users.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
11
api/migrations/20250101001000_create_persons.sql
Normal file
11
api/migrations/20250101001000_create_persons.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE IF NOT EXISTS persons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
age INTEGER NOT NULL,
|
||||
phone_number TEXT NOT NULL,
|
||||
checked_in BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
inside BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persons_name_trgm ON persons USING GIN (name gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_persons_phone_number ON persons (phone_number);
|
||||
120
api/src/auth.rs
Normal file
120
api/src/auth.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::AppState;
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use rocket::http::CookieJar;
|
||||
use rocket::request::{FromRequest, Outcome};
|
||||
use rocket::{async_trait, http::Status, Request, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: i32,
|
||||
pub username: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
pub iss: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub user_id: i32,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub fn generate_token(state: &AppState, user_id: i32, username: &str) -> Result<String, ApiError> {
|
||||
let now = chrono::Utc::now();
|
||||
let exp = now
|
||||
.checked_add_signed(chrono::Duration::seconds(state.jwt_ttl_seconds))
|
||||
.ok_or_else(|| ApiError::internal("Kunde inte skapa utgångstid för token."))?;
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id,
|
||||
username: username.to_string(),
|
||||
exp: exp.timestamp() as usize,
|
||||
iat: now.timestamp() as usize,
|
||||
iss: state.jwt_issuer.clone(),
|
||||
};
|
||||
|
||||
let header = Header::new(Algorithm::HS256);
|
||||
encode(
|
||||
&header,
|
||||
&claims,
|
||||
&EncodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| {
|
||||
eprintln!("Failed to encode JWT: {e:?}");
|
||||
ApiError::internal("Misslyckades att skapa token.")
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<'r> FromRequest<'r> for AuthUser {
|
||||
type Error = ApiError;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let state_outcome = request.guard::<&State<AppState>>().await;
|
||||
let state = match state_outcome {
|
||||
Outcome::Success(state) => state,
|
||||
Outcome::Error((status, _)) => {
|
||||
return Outcome::Error((
|
||||
status,
|
||||
ApiError::internal("Kunde inte läsa applikationsstatus."),
|
||||
));
|
||||
}
|
||||
Outcome::Forward(_) => {
|
||||
return Outcome::Error((
|
||||
Status::InternalServerError,
|
||||
ApiError::internal("Kunde inte läsa applikationsstatus."),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let jar_outcome = request.guard::<&CookieJar>().await;
|
||||
let jar = match jar_outcome {
|
||||
Outcome::Success(jar) => jar,
|
||||
Outcome::Error((status, _)) => {
|
||||
return Outcome::Error((status, ApiError::internal("Kunde inte läsa cookies.")));
|
||||
}
|
||||
Outcome::Forward(_) => {
|
||||
return Outcome::Error((
|
||||
Status::Unauthorized,
|
||||
ApiError::unauthorized("Ingen åtkomsttoken hittades."),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let cookie = match jar.get(&state.cookie_name) {
|
||||
Some(cookie) => cookie,
|
||||
None => {
|
||||
return Outcome::Error((
|
||||
Status::Unauthorized,
|
||||
ApiError::unauthorized("Inloggning krävs."),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let token = cookie.value();
|
||||
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
validation.set_issuer(&[state.jwt_issuer.clone()]);
|
||||
|
||||
match decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||
&validation,
|
||||
) {
|
||||
Ok(data) => Outcome::Success(AuthUser {
|
||||
user_id: data.claims.sub,
|
||||
username: data.claims.username,
|
||||
}),
|
||||
Err(err) => {
|
||||
eprintln!("JWT decode error: {err:?}");
|
||||
Outcome::Error((
|
||||
Status::Unauthorized,
|
||||
ApiError::unauthorized("Ogiltig eller utgången session."),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
api/src/config.rs
Normal file
64
api/src/config.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use std::env;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("Saknar miljövariabel: {0}")]
|
||||
MissingEnv(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
pub database_max_connections: u32,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_issuer: String,
|
||||
pub jwt_ttl_seconds: i64,
|
||||
pub cookie_name: String,
|
||||
pub cookie_secure: bool,
|
||||
pub admin_username: String,
|
||||
pub admin_password: String,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.map_err(|_| ConfigError::MissingEnv("DATABASE_URL".to_string()))?;
|
||||
let jwt_secret = env::var("JWT_SECRET")
|
||||
.map_err(|_| ConfigError::MissingEnv("JWT_SECRET".to_string()))?;
|
||||
|
||||
let database_max_connections = env::var("DATABASE_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(10);
|
||||
|
||||
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "vbytes-api".to_string());
|
||||
let jwt_ttl_seconds = env::var("JWT_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(24 * 60 * 60);
|
||||
|
||||
let cookie_name = env::var("JWT_COOKIE_NAME").unwrap_or_else(|_| "auth_token".to_string());
|
||||
let cookie_secure = env::var("JWT_COOKIE_SECURE")
|
||||
.ok()
|
||||
.map(|v| v.to_lowercase())
|
||||
.map(|v| matches!(v.as_str(), "true" | "1" | "yes"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let admin_username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string());
|
||||
let admin_password = env::var("ADMIN_PASSWORD").unwrap_or_else(|_| "admin123".to_string());
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
database_max_connections,
|
||||
jwt_secret,
|
||||
jwt_issuer,
|
||||
jwt_ttl_seconds,
|
||||
cookie_name,
|
||||
cookie_secure,
|
||||
admin_username,
|
||||
admin_password,
|
||||
})
|
||||
}
|
||||
}
|
||||
79
api/src/error.rs
Normal file
79
api/src/error.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use rocket::http::{ContentType, Status};
|
||||
use rocket::response::{Responder, Response};
|
||||
use rocket::Request;
|
||||
use serde::Serialize;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub status: Status,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(status: Status, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::new(Status::Unauthorized, message)
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(Status::BadRequest, message)
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(Status::NotFound, message)
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(Status::InternalServerError, message)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> Responder<'r, 'static> for ApiError {
|
||||
fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> {
|
||||
let payload = ErrorResponse {
|
||||
message: self.message,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&payload)
|
||||
.unwrap_or_else(|_| "{\"message\":\"Ett oväntat fel inträffade.\"}".to_string());
|
||||
|
||||
let mut response = Response::new();
|
||||
response.set_status(self.status);
|
||||
response.set_header(ContentType::JSON);
|
||||
response.set_sized_body(body.len(), Cursor::new(body));
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
eprintln!("Database error: {err:?}");
|
||||
ApiError::internal("Databasfel.")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bcrypt::BcryptError> for ApiError {
|
||||
fn from(err: bcrypt::BcryptError) -> Self {
|
||||
eprintln!("bcrypt error: {err:?}");
|
||||
ApiError::internal("Misslyckades att bearbeta lösenord.")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<jsonwebtoken::errors::Error> for ApiError {
|
||||
fn from(err: jsonwebtoken::errors::Error) -> Self {
|
||||
eprintln!("JWT error: {err:?}");
|
||||
ApiError::internal("Tokenfel.")
|
||||
}
|
||||
}
|
||||
1
api/src/lib.rs
Normal file
1
api/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
464
api/src/main.rs
Normal file
464
api/src/main.rs
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
mod auth;
|
||||
mod config;
|
||||
mod error;
|
||||
mod models;
|
||||
mod seed;
|
||||
|
||||
use auth::{generate_token, AuthUser};
|
||||
use config::AppConfig;
|
||||
use error::ApiError;
|
||||
use models::{
|
||||
LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse,
|
||||
PersonsResponse, User,
|
||||
};
|
||||
use rocket::http::{Cookie, CookieJar, SameSite, Status};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::time::Duration;
|
||||
use rocket::{get, post, routes, State};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, QueryBuilder};
|
||||
|
||||
pub struct AppState {
|
||||
pub db: PgPool,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_issuer: String,
|
||||
pub jwt_ttl_seconds: i64,
|
||||
pub cookie_name: String,
|
||||
pub cookie_secure: bool,
|
||||
}
|
||||
|
||||
#[rocket::main]
|
||||
async fn main() -> Result<(), rocket::Error> {
|
||||
dotenvy::dotenv().ok();
|
||||
let config = AppConfig::from_env().expect("Kunde inte läsa konfiguration");
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(config.database_max_connections)
|
||||
.connect(&config.database_url)
|
||||
.await
|
||||
.expect("Kunde inte ansluta till databasen");
|
||||
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Misslyckades att köra migrationer");
|
||||
|
||||
seed::run(&pool, &config.admin_username, &config.admin_password)
|
||||
.await
|
||||
.expect("Misslyckades att seed:a databasen");
|
||||
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
jwt_secret: config.jwt_secret,
|
||||
jwt_issuer: config.jwt_issuer,
|
||||
jwt_ttl_seconds: config.jwt_ttl_seconds,
|
||||
cookie_name: config.cookie_name,
|
||||
cookie_secure: config.cookie_secure,
|
||||
};
|
||||
|
||||
let rocket = rocket::build()
|
||||
.manage(state)
|
||||
.mount("/", routes![healthz, login, logout])
|
||||
.mount(
|
||||
"/persons",
|
||||
routes![
|
||||
search_persons,
|
||||
list_checked_in,
|
||||
checkin_person,
|
||||
checkout_person,
|
||||
mark_inside,
|
||||
mark_outside,
|
||||
create_person
|
||||
],
|
||||
);
|
||||
|
||||
rocket.launch().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/healthz")]
|
||||
fn healthz() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
#[post("/login", data = "<payload>")]
|
||||
async fn login(
|
||||
cookies: &CookieJar<'_>,
|
||||
state: &State<AppState>,
|
||||
payload: Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, ApiError> {
|
||||
let username = payload.username.trim();
|
||||
let password = payload.password.trim();
|
||||
|
||||
if username.is_empty() || password.is_empty() {
|
||||
return Err(ApiError::bad_request("Användarnamn och lösenord krävs."));
|
||||
}
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
SELECT id, username, password_hash, created_at
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("Felaktigt användarnamn eller lösenord."))?;
|
||||
|
||||
let valid = bcrypt::verify(password, &user.password_hash)
|
||||
.map_err(|_| ApiError::unauthorized("Felaktigt användarnamn eller lösenord."))?;
|
||||
|
||||
if !valid {
|
||||
return Err(ApiError::unauthorized(
|
||||
"Felaktigt användarnamn eller lösenord.",
|
||||
));
|
||||
}
|
||||
|
||||
let token = generate_token(state.inner(), user.id, &user.username)?;
|
||||
|
||||
let mut cookie = Cookie::new(state.cookie_name.clone(), token);
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_same_site(Some(SameSite::Lax));
|
||||
cookie.set_path("/".to_string());
|
||||
cookie.set_max_age(Duration::seconds(state.inner().jwt_ttl_seconds));
|
||||
if state.cookie_secure {
|
||||
cookie.set_secure(true);
|
||||
}
|
||||
|
||||
cookies.add(cookie);
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
username: user.username,
|
||||
}))
|
||||
}
|
||||
|
||||
#[post("/logout")]
|
||||
fn logout(cookies: &CookieJar<'_>, state: &State<AppState>) -> Status {
|
||||
let mut cookie = Cookie::new(state.cookie_name.clone(), String::new());
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_same_site(Some(SameSite::Lax));
|
||||
cookie.set_path("/".to_string());
|
||||
if state.cookie_secure {
|
||||
cookie.set_secure(true);
|
||||
}
|
||||
|
||||
cookies.remove(cookie);
|
||||
Status::NoContent
|
||||
}
|
||||
|
||||
#[get("/search?<q>")]
|
||||
async fn search_persons(
|
||||
_user: AuthUser,
|
||||
state: &State<AppState>,
|
||||
q: &str,
|
||||
) -> Result<Json<PersonsResponse>, ApiError> {
|
||||
let query = q.trim();
|
||||
if query.is_empty() {
|
||||
return Err(ApiError::bad_request("Söktext krävs."));
|
||||
}
|
||||
|
||||
let like_pattern = format!("%{}%", query);
|
||||
let id_value = query.parse::<i32>().ok();
|
||||
|
||||
let persons = if let Some(id) = id_value {
|
||||
sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
SELECT id, name, age, phone_number, checked_in, inside
|
||||
FROM persons
|
||||
WHERE name ILIKE $1
|
||||
OR phone_number ILIKE $1
|
||||
OR id = $2
|
||||
ORDER BY name
|
||||
LIMIT 100
|
||||
"#,
|
||||
)
|
||||
.bind(&like_pattern)
|
||||
.bind(id)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
SELECT id, name, age, phone_number, checked_in, inside
|
||||
FROM persons
|
||||
WHERE name ILIKE $1
|
||||
OR phone_number ILIKE $1
|
||||
ORDER BY name
|
||||
LIMIT 100
|
||||
"#,
|
||||
)
|
||||
.bind(&like_pattern)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
let persons = persons.into_iter().map(PersonResponse::from).collect();
|
||||
Ok(Json(PersonsResponse { persons }))
|
||||
}
|
||||
|
||||
#[get("/checked-in?<q>&<status>&<checked>")]
|
||||
async fn list_checked_in(
|
||||
_user: AuthUser,
|
||||
state: &State<AppState>,
|
||||
q: Option<&str>,
|
||||
status: Option<&str>,
|
||||
checked: Option<&str>,
|
||||
) -> Result<Json<PersonsResponse>, ApiError> {
|
||||
let inside_filter = match status.map(|s| s.trim().to_lowercase()) {
|
||||
Some(value) if value.is_empty() || value == "all" => None,
|
||||
Some(value) if value == "inside" => Some(true),
|
||||
Some(value) if value == "outside" => Some(false),
|
||||
Some(_) => return Err(ApiError::bad_request("Ogiltigt statusvärde.")),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let checked_filter = match checked.map(|s| s.trim().to_lowercase()) {
|
||||
Some(value) if value.is_empty() || value == "all" => None,
|
||||
Some(value) if value == "true" || value == "checked" || value == "in" => Some(true),
|
||||
Some(value) if value == "false" || value == "unchecked" || value == "out" => Some(false),
|
||||
Some(_) => return Err(ApiError::bad_request("Ogiltigt filter för incheckning.")),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let search_term = q
|
||||
.map(|raw| raw.trim())
|
||||
.filter(|trimmed| !trimmed.is_empty())
|
||||
.map(|trimmed| trimmed.to_string());
|
||||
let like_pattern = search_term.as_ref().map(|s| format!("%{}%", s));
|
||||
let id_value = search_term.as_ref().and_then(|s| s.parse::<i32>().ok());
|
||||
|
||||
let mut qb = QueryBuilder::<sqlx::Postgres>::new(
|
||||
"SELECT id, name, age, phone_number, checked_in, inside FROM persons",
|
||||
);
|
||||
|
||||
let mut first_condition = true;
|
||||
let mut append_condition = |qb: &mut QueryBuilder<sqlx::Postgres>| {
|
||||
if first_condition {
|
||||
qb.push(" WHERE ");
|
||||
first_condition = false;
|
||||
} else {
|
||||
qb.push(" AND ");
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(checked) = checked_filter {
|
||||
append_condition(&mut qb);
|
||||
qb.push("checked_in = ").push_bind(checked);
|
||||
}
|
||||
|
||||
if let Some(inside) = inside_filter {
|
||||
append_condition(&mut qb);
|
||||
qb.push("inside = ").push_bind(inside);
|
||||
}
|
||||
|
||||
if let Some(like) = like_pattern.as_ref() {
|
||||
append_condition(&mut qb);
|
||||
qb.push("(");
|
||||
qb.push("name ILIKE ").push_bind(like);
|
||||
qb.push(" OR phone_number ILIKE ").push_bind(like);
|
||||
if let Some(id) = id_value {
|
||||
qb.push(" OR id = ").push_bind(id);
|
||||
}
|
||||
qb.push(")");
|
||||
}
|
||||
|
||||
if let Some(id) = id_value {
|
||||
qb.push(" ORDER BY CASE WHEN id = ")
|
||||
.push_bind(id)
|
||||
.push(" THEN 0 ELSE 1 END, id, name");
|
||||
} else {
|
||||
qb.push(" ORDER BY id, name");
|
||||
}
|
||||
if like_pattern.is_some() {
|
||||
qb.push(" LIMIT 1000");
|
||||
}
|
||||
|
||||
let persons = qb.build_query_as::<Person>().fetch_all(&state.db).await?;
|
||||
|
||||
let persons = persons.into_iter().map(PersonResponse::from).collect();
|
||||
Ok(Json(PersonsResponse { persons }))
|
||||
}
|
||||
|
||||
#[post("/<id>/checkin")]
|
||||
async fn checkin_person(
|
||||
_user: AuthUser,
|
||||
state: &State<AppState>,
|
||||
id: i32,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
update_checked_in(state, id, true).await
|
||||
}
|
||||
|
||||
#[post("/<id>/checkout")]
|
||||
async fn checkout_person(
|
||||
_user: AuthUser,
|
||||
state: &State<AppState>,
|
||||
id: i32,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
update_checked_in(state, id, false).await
|
||||
}
|
||||
|
||||
#[post("/<id>/inside")]
|
||||
async fn mark_inside(
|
||||
_user: AuthUser,
|
||||
state: &State<AppState>,
|
||||
id: i32,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
update_inside(state, id, true).await
|
||||
}
|
||||
|
||||
#[post("/<id>/outside")]
|
||||
async fn mark_outside(
|
||||
_user: AuthUser,
|
||||
state: &State<AppState>,
|
||||
id: i32,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
update_inside(state, id, false).await
|
||||
}
|
||||
|
||||
#[post("/", data = "<payload>")]
|
||||
async fn create_person(
|
||||
_user: AuthUser,
|
||||
state: &State<AppState>,
|
||||
payload: Json<NewPersonRequest>,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
let name = payload.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ApiError::bad_request("Namn får inte vara tomt."));
|
||||
}
|
||||
|
||||
if payload.age < 0 {
|
||||
return Err(ApiError::bad_request("Ålder måste vara noll eller högre."));
|
||||
}
|
||||
|
||||
let phone_number = payload.phone_number.trim();
|
||||
if phone_number.is_empty() {
|
||||
return Err(ApiError::bad_request("Telefonnummer krävs."));
|
||||
}
|
||||
|
||||
let age = payload.age;
|
||||
let checked_in = payload.checked_in.unwrap_or(false);
|
||||
let inside = payload.inside.unwrap_or(false);
|
||||
|
||||
let person = match payload.id {
|
||||
Some(id) => sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
INSERT INTO persons (id, name, age, phone_number, checked_in, inside)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, name, age, phone_number, checked_in, inside
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(age)
|
||||
.bind(phone_number)
|
||||
.bind(checked_in)
|
||||
.bind(inside)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
|
||||
None => sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
INSERT INTO persons (name, age, phone_number, checked_in, inside)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, age, phone_number, checked_in, inside
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(age)
|
||||
.bind(phone_number)
|
||||
.bind(checked_in)
|
||||
.bind(inside)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
|
||||
};
|
||||
|
||||
Ok(Json(PersonActionResponse {
|
||||
person: person.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_checked_in(
|
||||
state: &State<AppState>,
|
||||
id: i32,
|
||||
value: bool,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
let person = sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
UPDATE persons
|
||||
SET checked_in = $2,
|
||||
inside = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, name, age, phone_number, checked_in, inside
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(value)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
match person {
|
||||
Some(person) => Ok(Json(PersonActionResponse {
|
||||
person: person.into(),
|
||||
})),
|
||||
None => Err(ApiError::not_found("Personen hittades inte.")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_inside(
|
||||
state: &State<AppState>,
|
||||
id: i32,
|
||||
value: bool,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
let person = sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
UPDATE persons
|
||||
SET inside = $2
|
||||
WHERE id = $1
|
||||
AND checked_in = TRUE
|
||||
RETURNING id, name, age, phone_number, checked_in, inside
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(value)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
match person {
|
||||
Some(person) => Ok(Json(PersonActionResponse {
|
||||
person: person.into(),
|
||||
})),
|
||||
None => {
|
||||
let checked =
|
||||
sqlx::query_scalar::<_, bool>("SELECT checked_in FROM persons WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
match checked {
|
||||
Some(_) => Err(ApiError::bad_request(
|
||||
"Personen måste vara incheckad för att uppdatera inne/ute.",
|
||||
)),
|
||||
None => Err(ApiError::not_found("Personen hittades inte.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_db_error(err: sqlx::Error, context: &str) -> ApiError {
|
||||
if let sqlx::Error::Database(db_err) = &err {
|
||||
if let Some(code) = db_err.code() {
|
||||
if code == "23505" {
|
||||
return ApiError::bad_request(
|
||||
"Krock i databasen – kontrollera id eller telefonnummer.",
|
||||
);
|
||||
}
|
||||
}
|
||||
eprintln!("Database error ({}): {:?}", context, db_err);
|
||||
return ApiError::internal("Databasfel.");
|
||||
}
|
||||
|
||||
eprintln!("Database error ({}): {:?}", context, err);
|
||||
ApiError::internal("Databasfel.")
|
||||
}
|
||||
86
api/src/models.rs
Normal file
86
api/src/models.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use rocket::serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct Person {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub age: i32,
|
||||
pub phone_number: String,
|
||||
pub checked_in: bool,
|
||||
pub inside: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct LoginResponse {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct PersonResponse {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub age: i32,
|
||||
pub phone_number: String,
|
||||
pub checked_in: bool,
|
||||
pub inside: bool,
|
||||
pub under_ten: bool,
|
||||
}
|
||||
|
||||
impl From<Person> for PersonResponse {
|
||||
fn from(person: Person) -> Self {
|
||||
let under_ten = person.age < 10;
|
||||
PersonResponse {
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
age: person.age,
|
||||
phone_number: person.phone_number,
|
||||
checked_in: person.checked_in,
|
||||
inside: person.inside,
|
||||
under_ten,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct PersonsResponse {
|
||||
pub persons: Vec<PersonResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct PersonActionResponse {
|
||||
pub person: PersonResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct NewPersonRequest {
|
||||
pub name: String,
|
||||
pub age: i32,
|
||||
pub phone_number: String,
|
||||
#[serde(default)]
|
||||
pub id: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub checked_in: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub inside: Option<bool>,
|
||||
}
|
||||
110
api/src/seed.rs
Normal file
110
api/src/seed.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
use anyhow::Context;
|
||||
use bcrypt::hash;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub async fn run(pool: &PgPool, admin_username: &str, admin_password: &str) -> anyhow::Result<()> {
|
||||
ensure_admin_user(pool, admin_username, admin_password).await?;
|
||||
ensure_person_seed(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_admin_user(pool: &PgPool, username: &str, password: &str) -> anyhow::Result<()> {
|
||||
let exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM users WHERE username = $1)")
|
||||
.bind(username)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.context("Kunde inte kontrollera befintlig admin-användare")?;
|
||||
|
||||
if !exists {
|
||||
let password_hash = hash(password, bcrypt::DEFAULT_COST)
|
||||
.context("Misslyckades att hasha admin-lösenord")?;
|
||||
|
||||
sqlx::query("INSERT INTO users (username, password_hash) VALUES ($1, $2)")
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Misslyckades att skapa admin-användare")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_person_seed(pool: &PgPool) -> anyhow::Result<()> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM persons")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.context("Kunde inte räkna personer i databasen")?;
|
||||
|
||||
if count > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let persons = generate_people();
|
||||
|
||||
for person in persons {
|
||||
sqlx::query(
|
||||
"INSERT INTO persons (name, age, phone_number, checked_in, inside) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(&person.name)
|
||||
.bind(person.age)
|
||||
.bind(&person.phone_number)
|
||||
.bind(person.checked_in)
|
||||
.bind(person.inside)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Misslyckades att lägga till seed-person")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct PersonSeed {
|
||||
name: String,
|
||||
age: i32,
|
||||
phone_number: String,
|
||||
checked_in: bool,
|
||||
inside: bool,
|
||||
}
|
||||
|
||||
fn generate_people() -> Vec<PersonSeed> {
|
||||
let first_names = vec![
|
||||
"Alex", "Bianca", "Cecilia", "David", "Elias", "Fatima", "Gabriel", "Hanna", "Isak",
|
||||
"Johanna", "Karin", "Liam", "Maja", "Nils", "Olivia",
|
||||
];
|
||||
|
||||
let last_names = vec![
|
||||
"Andersson",
|
||||
"Berg",
|
||||
"Carlsson",
|
||||
"Dahl",
|
||||
"Ek",
|
||||
"Fransson",
|
||||
"Gustafsson",
|
||||
"Holm",
|
||||
"Isaksson",
|
||||
"Johansson",
|
||||
];
|
||||
|
||||
let mut people = Vec::with_capacity(first_names.len() * last_names.len());
|
||||
|
||||
let mut idx: usize = 0;
|
||||
for first in &first_names {
|
||||
for last in &last_names {
|
||||
let name = format!("{} {}", first, last);
|
||||
let age = 5 + ((idx * 11) % 60) as i32;
|
||||
let phone_number = format!("070{:03}{:04}", idx % 1_000, (idx * 37) % 10_000);
|
||||
people.push(PersonSeed {
|
||||
name,
|
||||
age,
|
||||
phone_number,
|
||||
checked_in: false,
|
||||
inside: false,
|
||||
});
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
people
|
||||
}
|
||||
92
web/src/routes/create/+page.server.ts
Normal file
92
web/src/routes/create/+page.server.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = formData.get('name')?.toString().trim() ?? '';
|
||||
const ageRaw = formData.get('age')?.toString().trim() ?? '';
|
||||
const phone = formData.get('phone_number')?.toString().trim() ?? '';
|
||||
const manualId = formData.get('manual_id')?.toString().trim() ?? '';
|
||||
const checkedIn = formData.get('checked_in') === 'on';
|
||||
const inside = formData.get('inside') === 'on';
|
||||
|
||||
const values = {
|
||||
name,
|
||||
age: ageRaw,
|
||||
phone_number: phone,
|
||||
manual_id: manualId,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return fail(400, {
|
||||
errors: { name: 'Ange ett namn.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
const parsedAge = Number.parseInt(ageRaw, 10);
|
||||
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
||||
return fail(400, {
|
||||
errors: { age: 'Ålder måste vara ett heltal större än eller lika med 0.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
return fail(400, {
|
||||
errors: { phone_number: 'Ange ett telefonnummer.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
age: parsedAge,
|
||||
phone_number: phone,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
};
|
||||
|
||||
if (manualId.length > 0) {
|
||||
const parsedId = Number.parseInt(manualId, 10);
|
||||
if (Number.isNaN(parsedId) || parsedId < 0) {
|
||||
return fail(400, {
|
||||
errors: { manual_id: 'ID måste vara ett positivt heltal om det anges.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
payload.id = parsedId;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/persons', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Kunde inte skapa personen.';
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
message = body.message ?? message;
|
||||
} catch {
|
||||
if (text.trim().length > 0) {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
|
||||
return fail(response.status, {
|
||||
errors: { general: message },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: 'Personen har lagts till.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -1,217 +1,142 @@
|
|||
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
'use client';
|
||||
'use runes';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Person } from '$lib/types';
|
||||
const props = $props<import('./$types').PageData>();
|
||||
|
||||
let name = $state('');
|
||||
let age = $state('');
|
||||
let phoneNumber = $state('');
|
||||
let manualId = $state('');
|
||||
let checkedIn = $state(false);
|
||||
let inside = $state(false);
|
||||
let loading = $state(false);
|
||||
let errorMessage = $state('');
|
||||
let successMessage = $state('');
|
||||
|
||||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status === 401) {
|
||||
await goto('/login');
|
||||
return null;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
name = '';
|
||||
age = '';
|
||||
phoneNumber = '';
|
||||
manualId = '';
|
||||
checkedIn = false;
|
||||
inside = false;
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
errorMessage = '';
|
||||
successMessage = '';
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const trimmedPhone = phoneNumber.trim();
|
||||
const parsedAge = Number.parseInt(age, 10);
|
||||
const trimmedId = manualId.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
errorMessage = 'Ange ett namn.';
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
||||
errorMessage = 'Ålder måste vara ett heltal större än eller lika med 0.';
|
||||
return;
|
||||
}
|
||||
if (!trimmedPhone) {
|
||||
errorMessage = 'Ange ett telefonnummer.';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: trimmedName,
|
||||
age: parsedAge,
|
||||
phone_number: trimmedPhone,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
type FormValues = {
|
||||
name: string;
|
||||
age: string;
|
||||
phone_number: string;
|
||||
manual_id: string;
|
||||
checked_in: boolean;
|
||||
inside: boolean;
|
||||
};
|
||||
|
||||
if (trimmedId) {
|
||||
const parsedId = Number.parseInt(trimmedId, 10);
|
||||
if (Number.isNaN(parsedId) || parsedId < 0) {
|
||||
errorMessage = 'ID måste vara ett positivt heltal om det anges.';
|
||||
return;
|
||||
}
|
||||
type FormErrors = {
|
||||
name?: string;
|
||||
age?: string;
|
||||
phone_number?: string;
|
||||
manual_id?: string;
|
||||
general?: string;
|
||||
};
|
||||
|
||||
// Check that the ID is not already in use
|
||||
try {
|
||||
const idCheckResponse = await apiFetch(
|
||||
`/api/persons/search?q=${encodeURIComponent(trimmedId)}`
|
||||
);
|
||||
if (idCheckResponse) {
|
||||
if (idCheckResponse.ok) {
|
||||
const existingData = await idCheckResponse.json();
|
||||
const collision = existingData.persons?.some(
|
||||
(person: Person) => person.id === parsedId
|
||||
);
|
||||
if (collision) {
|
||||
errorMessage = 'Det angivna ID:t används redan.';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const text = await idCheckResponse.text();
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
errorMessage = body.message ?? 'Kontroll av ID misslyckades.';
|
||||
} catch {
|
||||
errorMessage = text || 'Kontroll av ID misslyckades.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ID uniqueness check failed', err);
|
||||
errorMessage = 'Kunde inte kontrollera ID. Försök igen.';
|
||||
return;
|
||||
}
|
||||
const defaults: FormValues = {
|
||||
name: '',
|
||||
age: '',
|
||||
phone_number: '',
|
||||
manual_id: '',
|
||||
checked_in: false,
|
||||
inside: false
|
||||
};
|
||||
|
||||
payload.id = parsedId;
|
||||
}
|
||||
const values = $derived({
|
||||
...defaults,
|
||||
...(props.form?.values ?? {})
|
||||
} as FormValues);
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await apiFetch('/api/persons', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response) return;
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
errorMessage = body.message ?? 'Kunde inte skapa personen.';
|
||||
} catch {
|
||||
errorMessage = text || 'Kunde inte skapa personen.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
successMessage = 'Personen har lagts till.';
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
console.error('Create person failed', err);
|
||||
errorMessage = 'Ett oväntat fel inträffade vid skapandet.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
|
||||
const success = $derived(props.form?.success ?? null);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-slate-800">Lägg till person</h2>
|
||||
<p class="mb-4 text-sm text-slate-500">Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.</p>
|
||||
<form class="grid gap-4 md:grid-cols-2" onsubmit={handleSubmit}>
|
||||
<form method="POST" class="grid gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
name="name"
|
||||
value={values.name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
{#if errors.name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="age">Ålder</label>
|
||||
<input
|
||||
type="number"
|
||||
id="age"
|
||||
name="age"
|
||||
min="0"
|
||||
bind:value={age}
|
||||
value={values.age}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
{#if errors.age}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.age}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone">Telefonnummer</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
bind:value={phoneNumber}
|
||||
name="phone_number"
|
||||
value={values.phone_number}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
{#if errors.phone_number}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id">ID (valfritt)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="manual-id"
|
||||
name="manual_id"
|
||||
min="0"
|
||||
bind:value={manualId}
|
||||
value={values.manual_id}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
{#if errors.manual_id}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="checked-in" type="checkbox" bind:checked={checkedIn} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<input
|
||||
id="checked-in"
|
||||
type="checkbox"
|
||||
name="checked_in"
|
||||
value="on"
|
||||
checked={values.checked_in}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<label for="checked-in" class="text-sm text-slate-700">Markera som incheckad</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="inside" type="checkbox" bind:checked={inside} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<input
|
||||
id="inside"
|
||||
type="checkbox"
|
||||
name="inside"
|
||||
value="on"
|
||||
checked={values.inside}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
|
||||
</div>
|
||||
{#if errorMessage}
|
||||
<p class="md:col-span-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||
{#if errors.general}
|
||||
<p class="md:col-span-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errors.general}</p>
|
||||
{/if}
|
||||
{#if successMessage}
|
||||
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{successMessage}</p>
|
||||
{#if success}
|
||||
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{success}</p>
|
||||
{/if}
|
||||
<div class="md:col-span-2 flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
{loading ? 'Sparar…' : 'Spara person'}
|
||||
Spara person
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetForm}
|
||||
disabled={loading}
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="reset"
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
Rensa fält
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue