Update Version: 1.4.0
This commit is contained in:
+6
-4
@@ -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
|
||||
|
||||
@@ -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.
|
||||
<p align="center">
|
||||
<img src="preview/dashboard.png" alt="Dashboard view" width="49%">
|
||||
<img src="preview/register.png" alt="Authentication view" width="49%">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "typstdrive",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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<String, (StatusCode, String)> {
|
||||
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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<AdminCreateUserRequest>,
|
||||
) -> Result<(StatusCode, Json<AdminUserView>), (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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
) -> Result<Json<Vec<AdminUserView>>, (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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Path(user_id): Path<String>,
|
||||
Json(payload): Json<UpdateUserRequest>,
|
||||
) -> Result<Json<AdminUserView>, (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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Path(user_id): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
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)
|
||||
}
|
||||
+11
-5
@@ -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<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<Json<User>, (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<LoginRequest>,
|
||||
) -> Result<(SignedCookieJar, Json<User>), (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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Mutex<HashMap<String, Arc<BroadcastGroup>>>>,
|
||||
pub db: AnyPool,
|
||||
pub key: Key,
|
||||
pub registration_enabled: bool,
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> 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))
|
||||
|
||||
@@ -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<S: Serializer>(v: &i64, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_bool(*v != 0)
|
||||
}
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<i64, D::Error> {
|
||||
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<bool>,
|
||||
}
|
||||
|
||||
#[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<bool>,
|
||||
pub username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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<AppState>,
|
||||
) -> Json<SetupStatus> {
|
||||
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<AppState>,
|
||||
jar: SignedCookieJar,
|
||||
Json(payload): Json<SetupRequest>,
|
||||
) -> Result<(SignedCookieJar, Json<User>), (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)))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { exportTypst } from '../ts/typst-api';
|
||||
import { text } from '../ts/yjs-setup';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect, documentStatsStore } from '../ts/store';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen, triggerLspReconnect, documentStatsStore, previewOpenStore } from '../ts/store';
|
||||
import { themes } from '../ts/themes';
|
||||
import { goto } from '$app/navigation';
|
||||
import ShareModal from './ShareModal.svelte';
|
||||
@@ -395,7 +395,7 @@
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] backdrop-blur-md select-none w-full relative z-[60]" style="background-color: var(--theme-bg); color: var(--theme-text); border-color: var(--theme-border);">
|
||||
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] backdrop-blur-md select-none w-full relative z-[70]" style="background-color: var(--theme-bg); color: var(--theme-text); border-color: var(--theme-border);">
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -427,7 +427,7 @@
|
||||
File
|
||||
</button>
|
||||
{#if activeMenu === 'file'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100] max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||
<button onclick={() => { activeMenu = null; goto('/dashboard'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">New / Open</button>
|
||||
<button onclick={() => { activeMenu = null; openInfo(); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)]">Document Info</button>
|
||||
{#if !isViewer}
|
||||
@@ -561,19 +561,48 @@
|
||||
{/if}
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center bg-gray-100/50 dark:bg-zinc-900/50 rounded-md p-0.5 border border-gray-200 dark:border-white/10">
|
||||
<button onclick={handlePrint} class="flex items-center gap-1 px-3 py-1 text-xs font-bold text-[var(--theme-bg)] bg-[var(--theme-text)] hover:opacity-80 rounded transition-all shadow-sm" title="Print Document">
|
||||
<Icon icon="mdi:printer" class="text-sm" />
|
||||
Print
|
||||
</button>
|
||||
<button onclick={() => handleExport('typ')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Download .typ source">TYP</button>
|
||||
<button onclick={() => handleExport('svg')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as SVG">SVG</button>
|
||||
<button onclick={() => handleExport('png')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as PNG">PNG</button>
|
||||
<button onclick={() => handleExport('pdf')} class="flex items-center gap-1 px-3 py-1 text-xs font-bold text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h2"/><path d="M8 17h2"/><path d="M14 13h2"/><path d="M14 17h2"/></svg>
|
||||
PDF
|
||||
|
||||
<button
|
||||
onclick={() => ($previewOpenStore = !$previewOpenStore)}
|
||||
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium transition-colors rounded-md {$previewOpenStore ? 'text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10' : 'text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 dark:hover:bg-blue-900/40'}"
|
||||
title={$previewOpenStore ? 'Hide preview' : 'Show preview'}
|
||||
>
|
||||
<Icon icon={$previewOpenStore ? 'mdi:eye-off-outline' : 'mdi:eye-outline'} class="text-[16px]" />
|
||||
Preview
|
||||
</button>
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<button onclick={handlePrint} class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors" title="Print Document">
|
||||
<Icon icon="mdi:printer" class="text-[16px]" />
|
||||
Print
|
||||
</button>
|
||||
|
||||
<div class="relative action-menu-container">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'export' ? null : 'export'; }}
|
||||
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded-md transition-colors {activeMenu === 'export' ? 'ring-2 ring-blue-500/30' : ''}"
|
||||
>
|
||||
<Icon icon="mdi:export-variant" class="text-[16px]" />
|
||||
Export
|
||||
<Icon icon="mdi:chevron-down" class="text-sm opacity-70" />
|
||||
</button>
|
||||
{#if activeMenu === 'export'}
|
||||
<div class="absolute right-0 top-full mt-1 w-44 bg-[var(--theme-bg)] rounded-xl shadow-xl border border-[var(--theme-border)] py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-2">
|
||||
<Icon icon="mdi:file-pdf-box" class="text-base text-red-500" /> PDF document
|
||||
</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-2">
|
||||
<Icon icon="mdi:code-braces" class="text-base text-purple-500" /> TYP source
|
||||
</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-2">
|
||||
<Icon icon="mdi:svg" class="text-base text-orange-500" /> SVG graphics
|
||||
</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1.5 text-sm text-[var(--theme-text)] hover:bg-[var(--theme-border)] flex items-center gap-2">
|
||||
<Icon icon="mdi:image" class="text-base text-blue-500" /> PNG image
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export const userStore = writable<User | null>(null);
|
||||
|
||||
@@ -12,6 +12,7 @@ export const commentReference = writable('');
|
||||
export const editorErrors = writable<Diagnostic[]>([]);
|
||||
export const documentStatsStore = writable<{pages: number; words: number; characters: number; characters_excluding_spaces: number} | null>(null);
|
||||
export const triggerLspReconnect = writable(0);
|
||||
export const previewOpenStore = writable(true);
|
||||
|
||||
export interface AwarenessUser {
|
||||
clientId: number;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import '../app.css';
|
||||
import { fetchUser } from '$lib/ts/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||
import { themes } from '$lib/ts/themes';
|
||||
|
||||
@@ -9,6 +11,17 @@
|
||||
let loaded = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
const setupRes = await fetch('/api/setup');
|
||||
if (setupRes.ok) {
|
||||
const { needs_setup } = await setupRes.json();
|
||||
if (needs_setup) {
|
||||
if ($page.url.pathname !== '/setup') {
|
||||
await goto('/setup');
|
||||
}
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await fetchUser();
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { compileTypst } from '$lib/ts/typst-api';
|
||||
import type { Diagnostic } from '$lib/ts/typst-api';
|
||||
import { page } from '$app/stores';
|
||||
import { commentsSidebarOpen, commentReference, editorViewStore, editorErrors, documentStatsStore } from '$lib/ts/store';
|
||||
import { commentsSidebarOpen, commentReference, editorViewStore, editorErrors, documentStatsStore, previewOpenStore } from '$lib/ts/store';
|
||||
|
||||
let svgs = $state<string[]>([]);
|
||||
let errors = $state<Diagnostic[]>([]);
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
function triggerCompile() {
|
||||
if (!text) return;
|
||||
if (!text || !$previewOpenStore) return;
|
||||
const content = text.toString();
|
||||
const docId = $page.params.id;
|
||||
compileTypst(content, docId)
|
||||
@@ -76,6 +76,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($previewOpenStore && initialized) {
|
||||
triggerCompile();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const docId = $page.params.id;
|
||||
if (!docId) return;
|
||||
@@ -122,20 +128,21 @@
|
||||
<Toolbar title={documentTitle} docId={$page.params.id} isViewer={isViewer} />
|
||||
|
||||
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative" oncontextmenu={handleContextMenu}>
|
||||
|
||||
|
||||
{#if !isViewer}
|
||||
<div class="w-full md:w-1/2 flex flex-col relative min-h-[50%] md:min-h-0 bg-transparent z-10 shadow-[1px_0_10px_rgba(0,0,0,0.05)] dark:shadow-[1px_0_10px_rgba(0,0,0,0.2)] border-r border-gray-200 dark:border-white/10">
|
||||
<div class="flex flex-col relative min-h-[50%] md:min-h-0 bg-transparent z-10 shadow-[1px_0_10px_rgba(0,0,0,0.05)] dark:shadow-[1px_0_10px_rgba(0,0,0,0.2)] {$previewOpenStore ? 'w-full md:w-1/2 border-r border-gray-200 dark:border-white/10' : 'w-full'}">
|
||||
{#if initialized}
|
||||
<Editor />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if $previewOpenStore || isViewer}
|
||||
<div class="{isViewer ? 'w-full' : 'w-full md:w-1/2'} relative bg-white/50 dark:bg-black/20 min-h-[50%] md:min-h-0 flex flex-col">
|
||||
<Preview {svgs} />
|
||||
<ErrorBanner {errors} />
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<DocFooter />
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let errorMsg = $state('');
|
||||
let registrationEnabled = $state(true);
|
||||
|
||||
async function login(e: Event) {
|
||||
e.preventDefault();
|
||||
@@ -33,8 +34,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($userStore) goto('/dashboard');
|
||||
onMount(async () => {
|
||||
if ($userStore) { goto('/dashboard'); return; }
|
||||
try {
|
||||
const res = await fetch('/api/setup');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
registrationEnabled = data.registration_enabled;
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -98,12 +106,14 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if registrationEnabled}
|
||||
<div class="text-sm text-center mt-6 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<span class="text-gray-500 dark:text-gray-400">New to TypstDrive? </span>
|
||||
<a href="/register" class="font-bold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors hover:underline">
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -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
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" onsubmit={register}>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon icon="mdi:account" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input id="username" name="username" type="text" required bind:value={username} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="Choose a username">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Email Address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon icon="mdi:email" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input id="email" name="email" type="email" required bind:value={email} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="[email protected]">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon icon="mdi:lock" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input id="password" name="password" type="password" required bind:value={password} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="••••••••">
|
||||
{#if registrationDisabled}
|
||||
<div class="flex flex-col items-center gap-4 py-4">
|
||||
<div class="flex items-center gap-3 w-full bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 p-4 rounded-xl border border-amber-200 dark:border-amber-500/20">
|
||||
<Icon icon="mdi:lock-outline" class="text-2xl flex-shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold text-sm">Registration Disabled</p>
|
||||
<p class="text-xs mt-0.5 text-amber-600 dark:text-amber-500">New account creation has been disabled by the administrator. Please contact your administrator to get an account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="flex items-center gap-2 text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg text-sm border border-red-200 dark:border-red-500/20 shadow-sm animate-in slide-in-from-top-1 fade-in duration-200">
|
||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||
<span class="font-medium">{errorMsg}</span>
|
||||
{:else}
|
||||
<form class="mt-8 space-y-6" onsubmit={register}>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon icon="mdi:account" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input id="username" name="username" type="text" required bind:value={username} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="Choose a username">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Email Address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon icon="mdi:email" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input id="email" name="email" type="email" required bind:value={email} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="[email protected]">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Icon icon="mdi:lock" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input id="password" name="password" type="password" required bind:value={password} class="block w-full rounded-xl border border-gray-300 dark:border-white/20 pl-10 pr-3 py-2.5 bg-white/50 dark:bg-black/40 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 sm:text-sm transition-all duration-200" placeholder="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="group w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent text-sm font-bold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 focus:ring-blue-500 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
|
||||
Register
|
||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if errorMsg}
|
||||
<div class="flex items-center gap-2 text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg text-sm border border-red-200 dark:border-red-500/20 shadow-sm animate-in slide-in-from-top-1 fade-in duration-200">
|
||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||
<span class="font-medium">{errorMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="group w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent text-sm font-bold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-zinc-900 focus:ring-blue-500 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
|
||||
Register
|
||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
<div class="text-sm text-center mt-6 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<span class="text-gray-500 dark:text-gray-400">Already have an account? </span>
|
||||
<a href="/login" class="font-bold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors hover:underline">
|
||||
|
||||
+430
-192
@@ -2,15 +2,25 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||
import { themes } from '$lib/ts/themes';
|
||||
import Icon from '@iconify/svelte';
|
||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
|
||||
type AdminUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
let activeSection = $state('account');
|
||||
|
||||
let username = $state('');
|
||||
let email = $state('');
|
||||
let isSaving = $state(false);
|
||||
let profileError = $state('');
|
||||
let profileSuccess = $state(false);
|
||||
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
@@ -19,10 +29,21 @@
|
||||
let passwordError = $state('');
|
||||
let passwordSuccess = $state(false);
|
||||
|
||||
let profileError = $state('');
|
||||
let profileSuccess = $state(false);
|
||||
let storageStats = $state<{ documents_size_bytes: number; files_size_bytes: number; total_size_bytes: number } | null>(null);
|
||||
|
||||
let storageStats = $state<{documents_size_bytes: number, files_size_bytes: number, total_size_bytes: number} | null>(null);
|
||||
let adminUsers = $state<AdminUser[]>([]);
|
||||
let adminLoading = $state(false);
|
||||
let adminError = $state('');
|
||||
let deletingUserId = $state<string | null>(null);
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
|
||||
let showCreateForm = $state(false);
|
||||
let createUsername = $state('');
|
||||
let createEmail = $state('');
|
||||
let createPassword = $state('');
|
||||
let createIsAdmin = $state(false);
|
||||
let createError = $state('');
|
||||
let createLoading = $state(false);
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
@@ -32,24 +53,91 @@
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!$userStore) {
|
||||
goto('/login');
|
||||
} else {
|
||||
username = $userStore.username;
|
||||
email = $userStore.email || '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/storage');
|
||||
if (res.ok) {
|
||||
storageStats = await res.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch storage stats", err);
|
||||
if (!$userStore) { goto('/login'); return; }
|
||||
username = $userStore.username;
|
||||
email = $userStore.email || '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/storage');
|
||||
if (res.ok) storageStats = await res.json();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
async function loadAdminUsers() {
|
||||
adminLoading = true;
|
||||
adminError = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/users');
|
||||
if (res.ok) {
|
||||
adminUsers = await res.json();
|
||||
} else {
|
||||
adminError = 'Failed to load users.';
|
||||
}
|
||||
} catch {
|
||||
adminError = 'Network error.';
|
||||
}
|
||||
adminLoading = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (activeSection === 'admin' && $userStore?.is_admin) {
|
||||
loadAdminUsers();
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleAdmin(user: AdminUser) {
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_admin: !user.is_admin })
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated: AdminUser = await res.json();
|
||||
adminUsers = adminUsers.map(u => u.id === updated.id ? updated : u);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: string) {
|
||||
deletingUserId = id;
|
||||
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
adminUsers = adminUsers.filter(u => u.id !== id);
|
||||
}
|
||||
deletingUserId = null;
|
||||
confirmDeleteId = null;
|
||||
}
|
||||
|
||||
async function createUser(e: Event) {
|
||||
e.preventDefault();
|
||||
createError = '';
|
||||
createLoading = true;
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: createUsername, email: createEmail, password: createPassword, is_admin: createIsAdmin })
|
||||
});
|
||||
if (!res.ok) {
|
||||
createError = await res.text() || 'Failed to create user.';
|
||||
} else {
|
||||
const created: AdminUser = await res.json();
|
||||
adminUsers = [...adminUsers, created];
|
||||
showCreateForm = false;
|
||||
createUsername = '';
|
||||
createEmail = '';
|
||||
createPassword = '';
|
||||
createIsAdmin = false;
|
||||
}
|
||||
} catch {
|
||||
createError = 'Network error.';
|
||||
}
|
||||
createLoading = false;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
userStore.set(null);
|
||||
@@ -57,75 +145,64 @@
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if ((!username || username === $userStore?.username) && (!email || email === $userStore?.email)) return;
|
||||
|
||||
if (username === $userStore?.username && email === $userStore?.email) return;
|
||||
isSaving = true;
|
||||
profileError = '';
|
||||
profileSuccess = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
profileError = text || "Failed to update profile";
|
||||
profileError = await res.text() || 'Failed to update profile';
|
||||
} else {
|
||||
const updatedUser = await res.json();
|
||||
userStore.set(updatedUser);
|
||||
userStore.set(await res.json());
|
||||
profileSuccess = true;
|
||||
}
|
||||
} catch (e) {
|
||||
profileError = "Network error occurred.";
|
||||
} catch {
|
||||
profileError = 'Network error occurred.';
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
passwordError = '';
|
||||
passwordSuccess = false;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
passwordError = "New passwords don't match.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) { passwordError = "New passwords don't match."; return; }
|
||||
isSavingPassword = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
passwordError = text || "Failed to change password";
|
||||
passwordError = await res.text() || 'Failed to change password';
|
||||
} else {
|
||||
passwordSuccess = true;
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
}
|
||||
} catch (e) {
|
||||
passwordError = "Network error occurred.";
|
||||
} catch {
|
||||
passwordError = 'Network error occurred.';
|
||||
}
|
||||
|
||||
isSavingPassword = false;
|
||||
}
|
||||
|
||||
const navItems = $derived([
|
||||
{ id: 'account', label: 'Account', icon: 'mdi:account-outline' },
|
||||
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
|
||||
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
|
||||
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - TypstDrive</title>
|
||||
<meta name="description" content="Manage your TypstDrive settings." />
|
||||
<title>Settings - TypstDrive</title>
|
||||
<meta name="description" content="Manage your TypstDrive settings." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
@@ -134,64 +211,89 @@
|
||||
<Icon icon="mdi:cog" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
||||
Settings
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<Icon icon="mdi:arrow-left" class="text-lg" />
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<Icon icon="mdi:arrow-left" class="text-lg" />
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-3xl w-full mx-auto py-10 px-4 sm:px-6 lg:px-8 flex-grow pb-32 mb-16 space-y-8">
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:account-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Account Settings
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10 flex flex-col gap-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-16 w-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 text-2xl font-bold border border-blue-500/20">
|
||||
{$userStore?.username?.[0]?.toUpperCase() || '?'}
|
||||
<div class="flex flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 gap-8">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-56 flex-shrink-0">
|
||||
<nav class="sticky top-24 space-y-1">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
onclick={() => activeSection = item.id}
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 {activeSection === item.id
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5'}"
|
||||
>
|
||||
<Icon icon={item.icon} class="text-lg flex-shrink-0" />
|
||||
{item.label}
|
||||
{#if item.id === 'admin'}
|
||||
<span class="ml-auto text-xs font-bold px-1.5 py-0.5 rounded-md {activeSection === 'admin' ? 'bg-white/20 text-white' : 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'}">Admin</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<button onclick={logout} class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all duration-150">
|
||||
<Icon icon="mdi:logout" class="text-lg flex-shrink-0" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 min-w-0 space-y-6 pb-16">
|
||||
|
||||
<!-- Account Section -->
|
||||
{#if activeSection === 'account'}
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:account-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Account Settings
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="h-14 w-14 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 text-2xl font-bold border border-blue-500/20 flex-shrink-0">
|
||||
{$userStore?.username?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base font-bold text-gray-900 dark:text-white">{$userStore?.username}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{$userStore?.email}</p>
|
||||
{#if $userStore?.is_admin}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 mt-1">
|
||||
<Icon icon="mdi:shield-crown-outline" class="text-sm" />
|
||||
Administrator
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white">{$userStore?.username}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your profile and preferences.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10 mb-6"></div>
|
||||
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-4">Profile</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<h3 class="text-md font-bold text-gray-900 dark:text-white">Profile</h3>
|
||||
|
||||
{#if profileError}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm">
|
||||
{profileError}
|
||||
</div>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm mb-4">{profileError}</div>
|
||||
{/if}
|
||||
|
||||
{#if profileSuccess}
|
||||
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm">
|
||||
Profile successfully updated.
|
||||
</div>
|
||||
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm mb-4">Profile updated successfully.</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="username-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||
<input id="username-input" type="text" bind:value={username} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label for="email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
|
||||
<input id="email-input" type="email" bind:value={email} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start mt-2">
|
||||
<button onclick={saveProfile} disabled={isSaving || (username === $userStore?.username && email === $userStore?.email)} class="bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white px-5 py-2 rounded-lg shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||
<input id="username-input" type="text" bind:value={username} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
|
||||
<input id="email-input" type="email" bind:value={email} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<button onclick={saveProfile} disabled={isSaving || (username === $userStore?.username && email === $userStore?.email)} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2 shadow-sm">
|
||||
{#if isSaving}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Saving...
|
||||
@@ -201,39 +303,32 @@
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10 my-6"></div>
|
||||
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-4">Change Password</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<h3 class="text-md font-bold text-gray-900 dark:text-white">Change Password</h3>
|
||||
|
||||
{#if passwordError}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm">
|
||||
{passwordError}
|
||||
</div>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm mb-4">{passwordError}</div>
|
||||
{/if}
|
||||
|
||||
{#if passwordSuccess}
|
||||
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm">
|
||||
Password successfully changed.
|
||||
</div>
|
||||
<div class="bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-3 rounded-lg text-sm mb-4">Password changed successfully.</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
||||
<input id="current-password" type="password" bind:value={currentPassword} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||
<input id="new-password" type="password" bind:value={newPassword} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
|
||||
<input id="confirm-password" type="password" bind:value={confirmPassword} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<div class="flex justify-start mt-2">
|
||||
<button onclick={changePassword} disabled={isSavingPassword || !currentPassword || !newPassword || !confirmPassword} class="bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white px-5 py-2 rounded-lg shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
||||
<input id="current-password" type="password" bind:value={currentPassword} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||
<input id="new-password" type="password" bind:value={newPassword} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
|
||||
<input id="confirm-password" type="password" bind:value={confirmPassword} class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" />
|
||||
</div>
|
||||
<button onclick={changePassword} disabled={isSavingPassword || !currentPassword || !newPassword || !confirmPassword} class="bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white px-5 py-2 rounded-lg text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{#if isSavingPassword}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Updating...
|
||||
@@ -244,83 +339,226 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button onclick={saveProfile} disabled={isSaving || (username === $userStore?.username && email === $userStore?.email)} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||
{#if isSaving}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Icon icon="mdi:content-save" class="text-lg" />
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
<button onclick={logout} class="bg-red-50 hover:bg-red-100 text-red-600 dark:bg-red-900/20 dark:hover:bg-red-900/40 dark:text-red-400 px-5 py-2.5 rounded-lg shadow-sm text-sm font-semibold transition-colors flex items-center gap-2">
|
||||
<Icon icon="mdi:logout" class="text-lg" />
|
||||
Sign Out
|
||||
</button>
|
||||
<!-- Theme Section -->
|
||||
{#if activeSection === 'theme'}
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||
<Icon icon="mdi:palette-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Theme Settings
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Customize the appearance of your editor and dashboard. These settings are saved to your browser.</p>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:palette-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Theme Settings
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Customize the appearance of your editor and dashboard. These settings are saved to your browser.</p>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:harddisk" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Storage Tracking
|
||||
</h2>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-black/20 rounded-lg p-5 border border-gray-200 dark:border-white/10">
|
||||
<div class="mb-2 flex justify-between items-end">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Total Space Used</span>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{storageStats ? formatBytes(storageStats.total_size_bytes) : 'Loading...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm mt-6">
|
||||
<div class="bg-white dark:bg-black/40 p-3 rounded-lg border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Documents</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{storageStats ? formatBytes(storageStats.documents_size_bytes) : '...'}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Storage Section -->
|
||||
{#if activeSection === 'storage'}
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||
<Icon icon="mdi:harddisk" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||
Storage
|
||||
</h2>
|
||||
<div class="mb-4 flex justify-between items-end">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Total Space Used</span>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{storageStats ? formatBytes(storageStats.total_size_bytes) : 'Loading...'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-black/40 p-3 rounded-lg border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-purple-500"></div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400">Images & Assets</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{storageStats ? formatBytes(storageStats.files_size_bytes) : '...'}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="bg-gray-50 dark:bg-black/30 p-4 rounded-xl border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-blue-500 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs">Documents</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{storageStats ? formatBytes(storageStats.documents_size_bytes) : '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-black/30 p-4 rounded-xl border border-gray-200 dark:border-white/10 flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full bg-purple-500 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs">Images & Assets</p>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{storageStats ? formatBytes(storageStats.files_size_bytes) : '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Admin Section -->
|
||||
{#if activeSection === 'admin' && $userStore?.is_admin}
|
||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Icon icon="mdi:shield-crown-outline" class="text-2xl text-amber-500 dark:text-amber-400" />
|
||||
User Management
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{adminUsers.length} user{adminUsers.length !== 1 ? 's' : ''}</span>
|
||||
<button
|
||||
onclick={() => { showCreateForm = !showCreateForm; createError = ''; }}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-semibold rounded-lg transition-colors {showCreateForm ? 'bg-gray-200 dark:bg-white/10 text-gray-700 dark:text-gray-300' : 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm'}"
|
||||
>
|
||||
<Icon icon={showCreateForm ? 'mdi:close' : 'mdi:account-plus-outline'} class="text-base" />
|
||||
{showCreateForm ? 'Cancel' : 'New User'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showCreateForm}
|
||||
<form onsubmit={createUser} class="mb-6 p-4 rounded-xl border border-blue-200 dark:border-blue-800/50 bg-blue-50/50 dark:bg-blue-900/10 space-y-3">
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Icon icon="mdi:account-plus-outline" class="text-blue-500" />
|
||||
Create New User
|
||||
</h3>
|
||||
|
||||
{#if createError}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-3 py-2 rounded-lg text-sm">{createError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
bind:value={createUsername}
|
||||
placeholder="username"
|
||||
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
bind:value={createEmail}
|
||||
placeholder="[email protected]"
|
||||
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Temporary Password</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
bind:value={createPassword}
|
||||
placeholder="Set a password the user can change later"
|
||||
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" bind:checked={createIsAdmin} class="w-4 h-4 rounded accent-amber-500" />
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1">
|
||||
<Icon icon="mdi:shield-crown-outline" class="text-amber-500 text-base" />
|
||||
Grant admin privileges
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createLoading}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||
>
|
||||
{#if createLoading}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-base" />
|
||||
Creating...
|
||||
{:else}
|
||||
<Icon icon="mdi:account-plus-outline" class="text-base" />
|
||||
Create User
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if adminError}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm mb-4">{adminError}</div>
|
||||
{/if}
|
||||
|
||||
{#if adminLoading}
|
||||
<div class="flex items-center justify-center py-12 text-gray-400 dark:text-gray-500">
|
||||
<Icon icon="mdi:loading" class="animate-spin text-2xl mr-2" />
|
||||
Loading users...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each adminUsers as user (user.id)}
|
||||
<div class="flex items-center gap-4 px-4 py-3 rounded-xl border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-black/20 group">
|
||||
<div class="h-9 w-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold text-sm flex-shrink-0">
|
||||
{user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">{user.username}</p>
|
||||
{#if user.is_admin}
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded-md bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 flex-shrink-0">Admin</span>
|
||||
{/if}
|
||||
{#if user.id === $userStore?.id}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-md bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 flex-shrink-0">You</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0 hidden sm:block">{formatDate(user.created_at)}</p>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
{#if user.id !== $userStore?.id}
|
||||
<button
|
||||
onclick={() => toggleAdmin(user)}
|
||||
title={user.is_admin ? 'Revoke admin' : 'Grant admin'}
|
||||
class="p-1.5 rounded-lg transition-colors {user.is_admin ? 'text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10' : 'text-gray-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10'}"
|
||||
>
|
||||
<Icon icon={user.is_admin ? 'mdi:shield-crown' : 'mdi:shield-crown-outline'} class="text-lg" />
|
||||
</button>
|
||||
{#if confirmDeleteId === user.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Delete?</span>
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
disabled={deletingUserId === user.id}
|
||||
class="text-xs px-2 py-1 rounded-md bg-red-600 hover:bg-red-700 text-white font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deletingUserId === user.id ? '...' : 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => confirmDeleteId = null}
|
||||
class="text-xs px-2 py-1 rounded-md bg-gray-200 hover:bg-gray-300 dark:bg-white/10 dark:hover:bg-white/20 text-gray-700 dark:text-gray-300 font-semibold transition-colors"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => confirmDeleteId = user.id}
|
||||
title="Delete user"
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Icon icon="mdi:delete-outline" class="text-lg" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let username = $state('');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let errorMsg = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSetup(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errorMsg = "Passwords don't match.";
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
errorMsg = "Password must be at least 8 characters.";
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
errorMsg = await res.text() || 'Setup failed.';
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await res.json();
|
||||
userStore.set(user);
|
||||
goto('/dashboard');
|
||||
} catch (e: any) {
|
||||
errorMsg = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Setup - TypstDrive</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center px-4 py-16">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 text-white mb-4 shadow-lg">
|
||||
<Icon icon="mdi:shield-crown-outline" class="text-3xl" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Welcome to TypstDrive</h1>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">Create your admin account to get started.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-black/20 rounded-2xl shadow-xl border border-gray-200 dark:border-white/10 p-8">
|
||||
<div class="flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-sm px-4 py-3 rounded-lg mb-6 border border-blue-200 dark:border-blue-800/50">
|
||||
<Icon icon="mdi:information-outline" class="text-lg flex-shrink-0" />
|
||||
<span>This is a one-time setup. The account you create here will have full admin privileges.</span>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm mb-5 border border-red-200 dark:border-red-800/50">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSetup} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
bind:value={username}
|
||||
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
bind:value={email}
|
||||
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={password}
|
||||
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Min. 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassword}
|
||||
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Repeat password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold py-2.5 rounded-lg transition-colors mt-2 shadow-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||
Creating account...
|
||||
{:else}
|
||||
<Icon icon="mdi:shield-check-outline" class="text-lg" />
|
||||
Create Admin Account
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user