From 7afd726188560dee567a30b11ca43a2fa46facd0 Mon Sep 17 00:00:00 2001
From: SirBlobby
Date: Thu, 14 May 2026 18:40:27 -0400
Subject: [PATCH] Update Version: 1.4.0
---
Dockerfile | 10 +-
README.md | 20 +-
docker-compose.yml | 2 +
package.json | 2 +-
server/Cargo.toml | 2 +-
server/src/admin.rs | 140 +++++++
server/src/auth.rs | 16 +-
server/src/db/postgres.rs | 21 +-
server/src/db/sqlite.rs | 13 +-
server/src/main.rs | 11 +
server/src/models.rs | 53 +++
server/src/setup.rs | 73 ++++
src/lib/components/Toolbar.svelte | 59 ++-
src/lib/ts/auth.ts | 1 +
src/lib/ts/store.ts | 1 +
src/routes/+layout.svelte | 13 +
src/routes/doc/[id]/+page.svelte | 17 +-
src/routes/login/+page.svelte | 14 +-
src/routes/register/+page.svelte | 102 +++--
src/routes/settings/+page.svelte | 622 +++++++++++++++++++++---------
src/routes/setup/+page.svelte | 137 +++++++
21 files changed, 1051 insertions(+), 278 deletions(-)
create mode 100644 server/src/admin.rs
create mode 100644 server/src/setup.rs
create mode 100644 src/routes/setup/+page.svelte
diff --git a/Dockerfile b/Dockerfile
index b346358..ad51f3e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -33,10 +33,12 @@ COPY --from=backend-builder /app/server/target/release/server /app/server
# Postgres: postgres://user:pass@host:5432/typstdrive
# DB_TYPE Database backend: "sqlite" or "postgres"
# Auto-detected from DATABASE_URL if not set.
-# COOKIE_SECRET 64+ byte secret for signing session cookies.
-# If unset, a random key is generated on each start
-# and all sessions are invalidated on restart.
-# RUST_LOG Log filter (default: server=debug,tower_http=debug)
+# COOKIE_SECRET 64+ byte secret for signing session cookies.
+# If unset, a random key is generated on each start
+# and all sessions are invalidated on restart.
+# ALLOW_REGISTRATION Set to "false" to disable public registration.
+# Admins can still create accounts via the admin panel.
+# RUST_LOG Log filter (default: server=debug,tower_http=debug)
ENV PORT=3000
ENV STATIC_DIR=/app/build
ENV DATABASE_URL=sqlite:///data/typstdrive.db?mode=rwc
diff --git a/README.md b/README.md
index f280126..985ebef 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# TypstDrive
-[](https://github.com/your-username/typstdrive)
+[](https://github.com/your-username/typstdrive)
[](https://typst.app/)
[](https://www.rust-lang.org/)
[](https://kit.svelte.dev/)
@@ -15,10 +15,11 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul
## Features
- **Real-Time Collaboration**: Powered by Yjs and CodeMirror 6, see changes and cursors from other users instantly.
-- **Instant Preview**: Compile Typst to SVG on the fly with sub-second latency, featuring interactive document zoom controls.
+- **Instant Preview**: Compile Typst to SVG on the fly with sub-second latency, featuring interactive document zoom controls and a collapsible preview pane.
- **Customizable Themes**: Choose from multiple editor themes (Catppuccin, Arch Linux, Cerberus) and toggle global dark mode.
- **Export Options**: Export your compiled documents directly to PDF, PNG, SVG, HTML, Markdown, Word, or LaTeX formats using internal conversion and Pandoc integrations.
- **User Authentication & Document Access**: Secure accounts, workspaces, and sharing features via email-based collaborator invitations (Editor or Viewer roles) for all your documents.
+- **Admin System**: First-run setup wizard creates an admin account. Admins can manage all users, create new accounts with temporary passwords, toggle admin privileges, and delete accounts from the Settings panel.
- **Presentation Mode**: Turn your documents into instant slideshows with built-in slide controls and a live drawing/annotation tool overlay.
- **Asset Management**: Upload and seamlessly use custom fonts and images directly within your documents.
@@ -85,6 +86,8 @@ TypstDrive is completely self-hostable. A Docker image packages both the Rust ba
3. Open your browser and navigate to `http://localhost:3000`.
+On first launch with no users in the database, you will be redirected to the **Setup** page to create the initial admin account.
+
### Data Storage
By default, TypstDrive uses **SQLite** — no separate database container required. All data is stored in a single file persisted via the `appdata` Docker volume.
@@ -102,6 +105,7 @@ All variables can be set in the `environment:` section of `docker-compose.yml` o
| `PORT` | `3000` | Port the HTTP server listens on. |
| `STATIC_DIR` | `/app/build` | Path to compiled frontend assets. |
| `COOKIE_SECRET` | *(random)* | 64+ byte secret used to sign session cookies. **If not set, a random key is generated on startup and all sessions are invalidated on every container restart.** Generate a stable value with: `openssl rand -hex 64` |
+| `ALLOW_REGISTRATION` | `true` | Set to `false` to disable public self-registration. When disabled, the register link is hidden on the login page and the registration endpoint returns 403. Admins can still create accounts from the Settings panel. |
| `RUST_LOG` | `server=debug,tower_http=debug` | Log filter. Set to `info` for quieter production logs. |
#### Example: production-ready `docker-compose.yml` snippet
@@ -112,6 +116,7 @@ environment:
- DB_TYPE=sqlite
- PORT=3000
- COOKIE_SECRET=your-64-plus-byte-secret-here
+ - ALLOW_REGISTRATION=false
- RUST_LOG=info
```
@@ -120,6 +125,15 @@ Generate a `COOKIE_SECRET`:
openssl rand -hex 64
```
+### Admin Panel
+
+The first account created via the setup wizard is automatically an administrator. Admins have access to an **Admin** section in Settings (`/settings`) which provides:
+
+- A list of all users with creation dates
+- **Create User** — set a username, email, and temporary password; optionally grant admin privileges immediately
+- **Toggle Admin** — promote or demote any other user
+- **Delete User** — permanently remove any account other than your own
+
## Contributing & Local Development
Clone the official Typst compiler into the `typst/` folder before building the backend:
@@ -156,4 +170,4 @@ The frontend dev server proxies API calls to `localhost:3000` automatically.
-
\ No newline at end of file
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 74412a8..8d66e7b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,6 +21,8 @@ services:
# Without this, sessions are cleared on every container restart.
# Generate one with: openssl rand -hex 64
# - COOKIE_SECRET=your-64-plus-byte-secret-here
+ # Set to "false" to disable public registration (admin panel can still create users)
+ - ALLOW_REGISTRATION=false
# Log verbosity (default: server=debug,tower_http=debug)
# - RUST_LOG=info
volumes:
diff --git a/package.json b/package.json
index 3f853b2..0a2bd1b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "typstdrive",
"private": true,
- "version": "1.3.0",
+ "version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite dev --host",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index f7fcbd5..2dbc9be 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "server"
-version = "1.3.0"
+version = "1.4.0"
edition = "2021"
[dependencies]
diff --git a/server/src/admin.rs b/server/src/admin.rs
new file mode 100644
index 0000000..6526006
--- /dev/null
+++ b/server/src/admin.rs
@@ -0,0 +1,140 @@
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ Json,
+};
+use axum_extra::extract::cookie::SignedCookieJar;
+use uuid::Uuid;
+use argon2::{
+ password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
+ Argon2,
+};
+
+use crate::{
+ models::{AdminCreateUserRequest, AdminUserView, UpdateUserRequest},
+ AppState,
+};
+
+async fn require_admin(state: &AppState, jar: &SignedCookieJar) -> Result {
+ let user_id = jar.get("session_user_id").map(|c| c.value().to_string())
+ .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?;
+
+ let is_admin: Option<(i64,)> = sqlx::query_as("SELECT is_admin FROM users WHERE id = ?")
+ .bind(&user_id)
+ .fetch_optional(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ match is_admin {
+ Some((v,)) if v != 0 => Ok(user_id),
+ _ => Err((StatusCode::FORBIDDEN, "Admin access required".to_string())),
+ }
+}
+
+pub async fn create_user(
+ State(state): State,
+ jar: SignedCookieJar,
+ Json(payload): Json,
+) -> Result<(StatusCode, Json), (StatusCode, String)> {
+ require_admin(&state, &jar).await?;
+
+ if payload.username.is_empty() || payload.email.is_empty() || payload.password.is_empty() {
+ return Err((StatusCode::BAD_REQUEST, "Username, email, and password are required".to_string()));
+ }
+
+ let salt = SaltString::generate(&mut OsRng);
+ let password_hash = Argon2::default()
+ .hash_password(payload.password.as_bytes(), &salt)
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .to_string();
+
+ let user_id = Uuid::new_v4().to_string();
+ let is_admin = payload.is_admin.unwrap_or(false);
+
+ let result = sqlx::query_as::<_, AdminUserView>(
+ "INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?) RETURNING id, username, email, is_admin, created_at"
+ )
+ .bind(&user_id)
+ .bind(&payload.username)
+ .bind(&payload.email)
+ .bind(&password_hash)
+ .bind(if is_admin { 1i64 } else { 0i64 })
+ .fetch_one(&state.db)
+ .await;
+
+ match result {
+ Ok(user) => Ok((StatusCode::CREATED, Json(user))),
+ Err(sqlx::Error::Database(err)) if err.is_unique_violation() => {
+ Err((StatusCode::CONFLICT, "Username or email already exists".to_string()))
+ }
+ Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
+ }
+}
+
+pub async fn list_users(
+ State(state): State,
+ jar: SignedCookieJar,
+) -> Result>, (StatusCode, String)> {
+ require_admin(&state, &jar).await?;
+
+ let users = sqlx::query_as::<_, AdminUserView>(
+ "SELECT id, username, email, is_admin, created_at FROM users ORDER BY created_at ASC"
+ )
+ .fetch_all(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ Ok(Json(users))
+}
+
+pub async fn update_user(
+ State(state): State,
+ jar: SignedCookieJar,
+ Path(user_id): Path,
+ Json(payload): Json,
+) -> Result, (StatusCode, String)> {
+ let requester_id = require_admin(&state, &jar).await?;
+
+ if let Some(is_admin) = payload.is_admin {
+ if !is_admin && requester_id == user_id {
+ return Err((StatusCode::BAD_REQUEST, "Cannot remove your own admin privileges".to_string()));
+ }
+ sqlx::query("UPDATE users SET is_admin = ? WHERE id = ?")
+ .bind(if is_admin { 1i64 } else { 0i64 })
+ .bind(&user_id)
+ .execute(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ }
+
+ let user = sqlx::query_as::<_, AdminUserView>(
+ "SELECT id, username, email, is_admin, created_at FROM users WHERE id = ?"
+ )
+ .bind(&user_id)
+ .fetch_optional(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
+
+ Ok(Json(user))
+}
+
+pub async fn delete_user(
+ State(state): State,
+ jar: SignedCookieJar,
+ Path(user_id): Path,
+) -> Result {
+ let requester_id = require_admin(&state, &jar).await?;
+
+ if requester_id == user_id {
+ return Err((StatusCode::BAD_REQUEST, "Cannot delete your own account via admin panel".to_string()));
+ }
+
+ sqlx::query("DELETE FROM users WHERE id = ?")
+ .bind(&user_id)
+ .execute(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ Ok(StatusCode::NO_CONTENT)
+}
diff --git a/server/src/auth.rs b/server/src/auth.rs
index 9e9857e..644c7dc 100644
--- a/server/src/auth.rs
+++ b/server/src/auth.rs
@@ -11,6 +11,8 @@ use crate::{
AppState,
};
+const USER_FIELDS: &str = "id, username, email, password_hash, is_admin";
+
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
@@ -20,6 +22,10 @@ pub async fn register(
State(state): State,
Json(payload): Json,
) -> Result, (StatusCode, String)> {
+ if !state.registration_enabled {
+ return Err((StatusCode::FORBIDDEN, "Registration is disabled on this instance".to_string()));
+ }
+
if payload.username.is_empty() || payload.password.is_empty() || payload.email.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Username, email, and password cannot be empty".to_string()));
}
@@ -34,7 +40,7 @@ pub async fn register(
let user_id = Uuid::new_v4().to_string();
let result = sqlx::query_as::<_, User>(
- "INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?) RETURNING id, username, email, password_hash"
+ "INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?) RETURNING id, username, email, password_hash, is_admin"
)
.bind(&user_id)
.bind(&payload.username)
@@ -57,7 +63,7 @@ pub async fn login(
jar: SignedCookieJar,
Json(payload): Json,
) -> Result<(SignedCookieJar, Json), (StatusCode, String)> {
- let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE email = ?")
+ let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE email = ?")
.bind(&payload.email)
.fetch_optional(&state.db)
.await
@@ -106,7 +112,7 @@ pub async fn update_profile(
match result {
Ok(_) => {
- let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
+ let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
@@ -135,7 +141,7 @@ pub async fn me(
None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())),
};
- let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
+ let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
@@ -159,7 +165,7 @@ pub async fn change_password(
return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string()));
}
- let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash FROM users WHERE id = ?")
+ let user = sqlx::query_as::<_, User>("SELECT id, username, email, password_hash, is_admin FROM users WHERE id = ?")
.bind(&user_id)
.fetch_optional(&state.db)
.await
diff --git a/server/src/db/postgres.rs b/server/src/db/postgres.rs
index a84da5d..58fd871 100644
--- a/server/src/db/postgres.rs
+++ b/server/src/db/postgres.rs
@@ -6,7 +6,9 @@ pub async fn init_schema(pool: &AnyPool) {
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
- password_hash TEXT NOT NULL
+ password_hash TEXT NOT NULL,
+ is_admin BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
)",
"CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
@@ -83,12 +85,13 @@ pub async fn init_schema(pool: &AnyPool) {
.expect("Failed to execute Postgres schema");
}
- // Idempotent migration for existing databases with TIMESTAMP columns
- sqlx::query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT")
- .execute(pool)
- .await
- .unwrap_or_else(|e| {
- eprintln!("Warning: public_role migration: {}", e);
- Default::default()
- });
+ // Idempotent migrations for existing databases
+ let migrations = [
+ "ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT",
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE",
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
+ ];
+ for stmt in &migrations {
+ sqlx::query(stmt).execute(pool).await.unwrap_or_else(|_| Default::default());
+ }
}
diff --git a/server/src/db/sqlite.rs b/server/src/db/sqlite.rs
index b2279ab..1240f31 100644
--- a/server/src/db/sqlite.rs
+++ b/server/src/db/sqlite.rs
@@ -11,7 +11,9 @@ pub async fn init_schema(pool: &AnyPool) {
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
- password_hash TEXT NOT NULL
+ password_hash TEXT NOT NULL,
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
)",
"CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
@@ -87,4 +89,13 @@ pub async fn init_schema(pool: &AnyPool) {
.await
.expect("Failed to execute SQLite schema");
}
+
+ // Idempotent migrations for existing databases
+ let migrations = [
+ "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0",
+ "ALTER TABLE users ADD COLUMN created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
+ ];
+ for stmt in &migrations {
+ let _ = sqlx::query(stmt).execute(pool).await;
+ }
}
diff --git a/server/src/main.rs b/server/src/main.rs
index 0deb4d4..2789072 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -12,6 +12,7 @@ use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
+mod admin;
mod auth;
mod compiler;
mod db;
@@ -20,6 +21,7 @@ mod folders;
mod files;
mod handlers;
mod models;
+mod setup;
mod world;
mod collab;
@@ -32,6 +34,7 @@ pub struct AppState {
pub bcast_map: Arc>>>,
pub db: AnyPool,
pub key: Key,
+ pub registration_enabled: bool,
}
impl axum::extract::FromRef for Key {
@@ -71,14 +74,22 @@ async fn main() {
}
};
+ let registration_enabled = std::env::var("ALLOW_REGISTRATION")
+ .map(|v| v.to_lowercase() != "false")
+ .unwrap_or(true);
+
let state = AppState {
compiler: Arc::new(Mutex::new(TypstCompiler::new())),
bcast_map: Arc::new(Mutex::new(HashMap::new())),
db,
key,
+ registration_enabled,
};
let api_routes = Router::new()
+ .route("/setup", get(setup::setup_status).post(setup::run_setup))
+ .route("/admin/users", get(admin::list_users).post(admin::create_user))
+ .route("/admin/users/{id}", patch(admin::update_user).delete(admin::delete_user))
.route("/compile", post(compile_handler))
.route("/export/{format}", post(export_handler))
.route("/export/pandoc/{format}", post(handlers::pandoc_export_handler))
diff --git a/server/src/models.rs b/server/src/models.rs
index 1f2eebf..037375d 100644
--- a/server/src/models.rs
+++ b/server/src/models.rs
@@ -1,6 +1,19 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
+// sqlx::Any maps SQLite INTEGER to i64 (BIGINT), not bool.
+// These helpers let us store is_admin as i64 in DB-mapped structs
+// while still serializing it as a JSON boolean for the frontend.
+mod serde_i64_bool {
+ use serde::{Deserialize, Deserializer, Serializer};
+ pub fn serialize(v: &i64, s: S) -> Result {
+ s.serialize_bool(*v != 0)
+ }
+ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result {
+ Ok(if bool::deserialize(d)? { 1 } else { 0 })
+ }
+}
+
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: String,
@@ -8,6 +21,18 @@ pub struct User {
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
+ #[serde(with = "serde_i64_bool")]
+ pub is_admin: i64,
+}
+
+#[derive(Debug, Serialize, Deserialize, FromRow)]
+pub struct AdminUserView {
+ pub id: String,
+ pub username: String,
+ pub email: String,
+ #[serde(with = "serde_i64_bool")]
+ pub is_admin: i64,
+ pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
@@ -161,3 +186,31 @@ pub struct InviteRequest {
pub email: String,
pub role: String,
}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct AdminCreateUserRequest {
+ pub username: String,
+ pub email: String,
+ pub password: String,
+ pub is_admin: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SetupRequest {
+ pub username: String,
+ pub email: String,
+ pub password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SetupStatus {
+ pub needs_setup: bool,
+ pub registration_enabled: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct UpdateUserRequest {
+ pub is_admin: Option,
+ pub username: Option,
+ pub email: Option,
+}
diff --git a/server/src/setup.rs b/server/src/setup.rs
new file mode 100644
index 0000000..50ca52f
--- /dev/null
+++ b/server/src/setup.rs
@@ -0,0 +1,73 @@
+use axum::{extract::State, http::StatusCode, Json};
+use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
+use uuid::Uuid;
+
+use argon2::{
+ password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
+ Argon2,
+};
+
+use crate::{
+ models::{SetupRequest, SetupStatus, User},
+ AppState,
+};
+
+pub async fn setup_status(
+ State(state): State,
+) -> Json {
+ let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
+ .fetch_one(&state.db)
+ .await
+ .unwrap_or((0,));
+
+ Json(SetupStatus {
+ needs_setup: count.0 == 0,
+ registration_enabled: state.registration_enabled,
+ })
+}
+
+pub async fn run_setup(
+ State(state): State,
+ jar: SignedCookieJar,
+ Json(payload): Json,
+) -> Result<(SignedCookieJar, Json), (StatusCode, String)> {
+ let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
+ .fetch_one(&state.db)
+ .await
+ .unwrap_or((0,));
+
+ if count.0 > 0 {
+ return Err((StatusCode::FORBIDDEN, "Setup has already been completed".to_string()));
+ }
+
+ if payload.username.is_empty() || payload.password.is_empty() || payload.email.is_empty() {
+ return Err((StatusCode::BAD_REQUEST, "Username, email, and password cannot be empty".to_string()));
+ }
+
+ let salt = SaltString::generate(&mut OsRng);
+ let password_hash = Argon2::default()
+ .hash_password(payload.password.as_bytes(), &salt)
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .to_string();
+
+ let user_id = Uuid::new_v4().to_string();
+
+ let user = sqlx::query_as::<_, User>(
+ "INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?) RETURNING id, username, email, password_hash, is_admin"
+ )
+ .bind(&user_id)
+ .bind(&payload.username)
+ .bind(&payload.email)
+ .bind(&password_hash)
+ .bind(1i64)
+ .fetch_one(&state.db)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ let mut cookie = Cookie::new("session_user_id", user.id.clone());
+ cookie.set_http_only(true);
+ cookie.set_same_site(SameSite::Lax);
+ cookie.set_path("/");
+
+ Ok((jar.add(cookie), Json(user)))
+}
diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte
index a30f0fe..fb7a40a 100644
--- a/src/lib/components/Toolbar.svelte
+++ b/src/lib/components/Toolbar.svelte
@@ -1,7 +1,7 @@
@@ -98,12 +106,14 @@
+ {#if registrationEnabled}
New to TypstDrive?
Create an account
+ {/if}
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte
index aacde9e..ece3648 100644
--- a/src/routes/register/+page.svelte
+++ b/src/routes/register/+page.svelte
@@ -9,6 +9,16 @@
let email = $state('');
let password = $state('');
let errorMsg = $state('');
+ let registrationDisabled = $state(false);
+
+ onMount(async () => {
+ if ($userStore) { goto('/dashboard'); return; }
+ const res = await fetch('/api/setup');
+ if (res.ok) {
+ const data = await res.json();
+ registrationDisabled = !data.registration_enabled;
+ }
+ });
async function register(e: Event) {
e.preventDefault();
@@ -72,51 +82,63 @@
Join TypstDrive to start collaborating
-