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
|
# Postgres: postgres://user:pass@host:5432/typstdrive
|
||||||
# DB_TYPE Database backend: "sqlite" or "postgres"
|
# DB_TYPE Database backend: "sqlite" or "postgres"
|
||||||
# Auto-detected from DATABASE_URL if not set.
|
# Auto-detected from DATABASE_URL if not set.
|
||||||
# COOKIE_SECRET 64+ byte secret for signing session cookies.
|
# COOKIE_SECRET 64+ byte secret for signing session cookies.
|
||||||
# If unset, a random key is generated on each start
|
# If unset, a random key is generated on each start
|
||||||
# and all sessions are invalidated on restart.
|
# and all sessions are invalidated on restart.
|
||||||
# RUST_LOG Log filter (default: server=debug,tower_http=debug)
|
# 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 PORT=3000
|
||||||
ENV STATIC_DIR=/app/build
|
ENV STATIC_DIR=/app/build
|
||||||
ENV DATABASE_URL=sqlite:///data/typstdrive.db?mode=rwc
|
ENV DATABASE_URL=sqlite:///data/typstdrive.db?mode=rwc
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# TypstDrive
|
# TypstDrive
|
||||||
|
|
||||||
[](https://github.com/your-username/typstdrive)
|
[](https://github.com/your-username/typstdrive)
|
||||||
[](https://typst.app/)
|
[](https://typst.app/)
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](https://kit.svelte.dev/)
|
[](https://kit.svelte.dev/)
|
||||||
@@ -15,10 +15,11 @@ TypstDrive is a collaborative web editor for Typst. With built-in dark mode, mul
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-Time Collaboration**: Powered by Yjs and CodeMirror 6, see changes and cursors from other users instantly.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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`.
|
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
|
### 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.
|
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. |
|
| `PORT` | `3000` | Port the HTTP server listens on. |
|
||||||
| `STATIC_DIR` | `/app/build` | Path to compiled frontend assets. |
|
| `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` |
|
| `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. |
|
| `RUST_LOG` | `server=debug,tower_http=debug` | Log filter. Set to `info` for quieter production logs. |
|
||||||
|
|
||||||
#### Example: production-ready `docker-compose.yml` snippet
|
#### Example: production-ready `docker-compose.yml` snippet
|
||||||
@@ -112,6 +116,7 @@ environment:
|
|||||||
- DB_TYPE=sqlite
|
- DB_TYPE=sqlite
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- COOKIE_SECRET=your-64-plus-byte-secret-here
|
- COOKIE_SECRET=your-64-plus-byte-secret-here
|
||||||
|
- ALLOW_REGISTRATION=false
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -120,6 +125,15 @@ Generate a `COOKIE_SECRET`:
|
|||||||
openssl rand -hex 64
|
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
|
## Contributing & Local Development
|
||||||
|
|
||||||
Clone the official Typst compiler into the `typst/` folder before building the backend:
|
Clone the official Typst compiler into the `typst/` folder before building the backend:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ services:
|
|||||||
# Without this, sessions are cleared on every container restart.
|
# Without this, sessions are cleared on every container restart.
|
||||||
# Generate one with: openssl rand -hex 64
|
# Generate one with: openssl rand -hex 64
|
||||||
# - COOKIE_SECRET=your-64-plus-byte-secret-here
|
# - 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)
|
# Log verbosity (default: server=debug,tower_http=debug)
|
||||||
# - RUST_LOG=info
|
# - RUST_LOG=info
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "typstdrive",
|
"name": "typstdrive",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev --host",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[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,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const USER_FIELDS: &str = "id, username, email, password_hash, is_admin";
|
||||||
|
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2,
|
Argon2,
|
||||||
@@ -20,6 +22,10 @@ pub async fn register(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<RegisterRequest>,
|
Json(payload): Json<RegisterRequest>,
|
||||||
) -> Result<Json<User>, (StatusCode, String)> {
|
) -> 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() {
|
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()));
|
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 user_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
let result = sqlx::query_as::<_, User>(
|
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(&user_id)
|
||||||
.bind(&payload.username)
|
.bind(&payload.username)
|
||||||
@@ -57,7 +63,7 @@ pub async fn login(
|
|||||||
jar: SignedCookieJar,
|
jar: SignedCookieJar,
|
||||||
Json(payload): Json<LoginRequest>,
|
Json(payload): Json<LoginRequest>,
|
||||||
) -> Result<(SignedCookieJar, Json<User>), (StatusCode, String)> {
|
) -> 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)
|
.bind(&payload.email)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
@@ -106,7 +112,7 @@ pub async fn update_profile(
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
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)
|
.bind(&user_id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
@@ -135,7 +141,7 @@ pub async fn me(
|
|||||||
None => return Err((StatusCode::UNAUTHORIZED, "Not logged in".to_string())),
|
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)
|
.bind(&user_id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
@@ -159,7 +165,7 @@ pub async fn change_password(
|
|||||||
return Err((StatusCode::BAD_REQUEST, "Passwords cannot be empty".to_string()));
|
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)
|
.bind(&user_id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
email TEXT 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 (
|
"CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -83,12 +85,13 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
.expect("Failed to execute Postgres schema");
|
.expect("Failed to execute Postgres schema");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent migration for existing databases with TIMESTAMP columns
|
// Idempotent migrations for existing databases
|
||||||
sqlx::query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT")
|
let migrations = [
|
||||||
.execute(pool)
|
"ALTER TABLE documents ADD COLUMN IF NOT EXISTS public_role TEXT",
|
||||||
.await
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE",
|
||||||
.unwrap_or_else(|e| {
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')",
|
||||||
eprintln!("Warning: public_role migration: {}", e);
|
];
|
||||||
Default::default()
|
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,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
email TEXT 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 (
|
"CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -87,4 +89,13 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to execute SQLite schema");
|
.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 tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
mod admin;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod compiler;
|
mod compiler;
|
||||||
mod db;
|
mod db;
|
||||||
@@ -20,6 +21,7 @@ mod folders;
|
|||||||
mod files;
|
mod files;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod setup;
|
||||||
mod world;
|
mod world;
|
||||||
mod collab;
|
mod collab;
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ pub struct AppState {
|
|||||||
pub bcast_map: Arc<Mutex<HashMap<String, Arc<BroadcastGroup>>>>,
|
pub bcast_map: Arc<Mutex<HashMap<String, Arc<BroadcastGroup>>>>,
|
||||||
pub db: AnyPool,
|
pub db: AnyPool,
|
||||||
pub key: Key,
|
pub key: Key,
|
||||||
|
pub registration_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl axum::extract::FromRef<AppState> for Key {
|
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 {
|
let state = AppState {
|
||||||
compiler: Arc::new(Mutex::new(TypstCompiler::new())),
|
compiler: Arc::new(Mutex::new(TypstCompiler::new())),
|
||||||
bcast_map: Arc::new(Mutex::new(HashMap::new())),
|
bcast_map: Arc::new(Mutex::new(HashMap::new())),
|
||||||
db,
|
db,
|
||||||
key,
|
key,
|
||||||
|
registration_enabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
let api_routes = Router::new()
|
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("/compile", post(compile_handler))
|
||||||
.route("/export/{format}", post(export_handler))
|
.route("/export/{format}", post(export_handler))
|
||||||
.route("/export/pandoc/{format}", post(handlers::pandoc_export_handler))
|
.route("/export/pandoc/{format}", post(handlers::pandoc_export_handler))
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::FromRow;
|
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)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -8,6 +21,18 @@ pub struct User {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub password_hash: String,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
@@ -161,3 +186,31 @@ pub struct InviteRequest {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
pub role: 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">
|
<script lang="ts">
|
||||||
import { exportTypst } from '../ts/typst-api';
|
import { exportTypst } from '../ts/typst-api';
|
||||||
import { text } from '../ts/yjs-setup';
|
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 { themes } from '../ts/themes';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import ShareModal from './ShareModal.svelte';
|
import ShareModal from './ShareModal.svelte';
|
||||||
@@ -395,7 +395,7 @@
|
|||||||
|
|
||||||
<svelte:window onclick={handleWindowClick} />
|
<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 justify-between px-4 py-2.5">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
File
|
File
|
||||||
</button>
|
</button>
|
||||||
{#if activeMenu === 'file'}
|
{#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; 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>
|
<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}
|
{#if !isViewer}
|
||||||
@@ -562,18 +562,47 @@
|
|||||||
|
|
||||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
<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
|
||||||
<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">
|
onclick={() => ($previewOpenStore = !$previewOpenStore)}
|
||||||
<Icon icon="mdi:printer" class="text-sm" />
|
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'}"
|
||||||
Print
|
title={$previewOpenStore ? 'Hide preview' : 'Show preview'}
|
||||||
</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>
|
<Icon icon={$previewOpenStore ? 'mdi:eye-off-outline' : 'mdi:eye-outline'} class="text-[16px]" />
|
||||||
<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>
|
Preview
|
||||||
<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>
|
||||||
<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>
|
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||||
PDF
|
|
||||||
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type User = {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
is_admin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userStore = writable<User | null>(null);
|
export const userStore = writable<User | null>(null);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const commentReference = writable('');
|
|||||||
export const editorErrors = writable<Diagnostic[]>([]);
|
export const editorErrors = writable<Diagnostic[]>([]);
|
||||||
export const documentStatsStore = writable<{pages: number; words: number; characters: number; characters_excluding_spaces: number} | null>(null);
|
export const documentStatsStore = writable<{pages: number; words: number; characters: number; characters_excluding_spaces: number} | null>(null);
|
||||||
export const triggerLspReconnect = writable(0);
|
export const triggerLspReconnect = writable(0);
|
||||||
|
export const previewOpenStore = writable(true);
|
||||||
|
|
||||||
export interface AwarenessUser {
|
export interface AwarenessUser {
|
||||||
clientId: number;
|
clientId: number;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { fetchUser } from '$lib/ts/auth';
|
import { fetchUser } from '$lib/ts/auth';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { themeStore, darkModeStore } from '$lib/ts/store';
|
import { themeStore, darkModeStore } from '$lib/ts/store';
|
||||||
import { themes } from '$lib/ts/themes';
|
import { themes } from '$lib/ts/themes';
|
||||||
|
|
||||||
@@ -9,6 +11,17 @@
|
|||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
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();
|
await fetchUser();
|
||||||
loaded = true;
|
loaded = true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { compileTypst } from '$lib/ts/typst-api';
|
import { compileTypst } from '$lib/ts/typst-api';
|
||||||
import type { Diagnostic } from '$lib/ts/typst-api';
|
import type { Diagnostic } from '$lib/ts/typst-api';
|
||||||
import { page } from '$app/stores';
|
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 svgs = $state<string[]>([]);
|
||||||
let errors = $state<Diagnostic[]>([]);
|
let errors = $state<Diagnostic[]>([]);
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerCompile() {
|
function triggerCompile() {
|
||||||
if (!text) return;
|
if (!text || !$previewOpenStore) return;
|
||||||
const content = text.toString();
|
const content = text.toString();
|
||||||
const docId = $page.params.id;
|
const docId = $page.params.id;
|
||||||
compileTypst(content, docId)
|
compileTypst(content, docId)
|
||||||
@@ -76,6 +76,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($previewOpenStore && initialized) {
|
||||||
|
triggerCompile();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const docId = $page.params.id;
|
const docId = $page.params.id;
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
@@ -124,18 +130,19 @@
|
|||||||
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative" oncontextmenu={handleContextMenu}>
|
<main class="flex-1 flex flex-col md:flex-row overflow-hidden relative" oncontextmenu={handleContextMenu}>
|
||||||
|
|
||||||
{#if !isViewer}
|
{#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}
|
{#if initialized}
|
||||||
<Editor />
|
<Editor />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<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} />
|
<Preview {svgs} />
|
||||||
<ErrorBanner {errors} />
|
<ErrorBanner {errors} />
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<DocFooter />
|
<DocFooter />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
let email = $state('');
|
let email = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let errorMsg = $state('');
|
let errorMsg = $state('');
|
||||||
|
let registrationEnabled = $state(true);
|
||||||
|
|
||||||
async function login(e: Event) {
|
async function login(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -33,8 +34,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
if ($userStore) goto('/dashboard');
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -98,12 +106,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{#if registrationEnabled}
|
||||||
<div class="text-sm text-center mt-6 pt-4 border-t border-gray-200 dark:border-white/10">
|
<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>
|
<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">
|
<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
|
Create an account
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -9,6 +9,16 @@
|
|||||||
let email = $state('');
|
let email = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let errorMsg = $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) {
|
async function register(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -72,51 +82,63 @@
|
|||||||
Join TypstDrive to start collaborating
|
Join TypstDrive to start collaborating
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="mt-8 space-y-6" onsubmit={register}>
|
{#if registrationDisabled}
|
||||||
<div class="space-y-5">
|
<div class="flex flex-col items-center gap-4 py-4">
|
||||||
<div>
|
<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">
|
||||||
<label for="username" class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Username</label>
|
<Icon icon="mdi:lock-outline" class="text-2xl flex-shrink-0" />
|
||||||
<div class="relative">
|
<div>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<p class="font-semibold text-sm">Registration Disabled</p>
|
||||||
<Icon icon="mdi:account" class="text-gray-400 dark:text-gray-500" />
|
<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>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
{#if errorMsg}
|
<form class="mt-8 space-y-6" onsubmit={register}>
|
||||||
<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">
|
<div class="space-y-5">
|
||||||
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
<div>
|
||||||
<span class="font-medium">{errorMsg}</span>
|
<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>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="pt-2">
|
{#if errorMsg}
|
||||||
<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">
|
<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">
|
||||||
Register
|
<Icon icon="mdi:alert-circle" class="text-lg flex-shrink-0" />
|
||||||
<Icon icon="mdi:arrow-right" class="text-lg group-hover:translate-x-1 transition-transform" />
|
<span class="font-medium">{errorMsg}</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</form>
|
|
||||||
|
<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">
|
<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>
|
<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">
|
<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">
|
||||||
|
|||||||
+423
-185
@@ -2,15 +2,25 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { userStore } from '$lib/ts/auth';
|
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 Icon from '@iconify/svelte';
|
||||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||||
import Footer from '$lib/components/Footer.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 username = $state('');
|
||||||
let email = $state('');
|
let email = $state('');
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
|
let profileError = $state('');
|
||||||
|
let profileSuccess = $state(false);
|
||||||
|
|
||||||
let currentPassword = $state('');
|
let currentPassword = $state('');
|
||||||
let newPassword = $state('');
|
let newPassword = $state('');
|
||||||
@@ -19,10 +29,21 @@
|
|||||||
let passwordError = $state('');
|
let passwordError = $state('');
|
||||||
let passwordSuccess = $state(false);
|
let passwordSuccess = $state(false);
|
||||||
|
|
||||||
let profileError = $state('');
|
let storageStats = $state<{ documents_size_bytes: number; files_size_bytes: number; total_size_bytes: number } | null>(null);
|
||||||
let profileSuccess = $state(false);
|
|
||||||
|
|
||||||
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) {
|
function formatBytes(bytes: number) {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
@@ -32,24 +53,91 @@
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
function formatDate(iso: string) {
|
||||||
if (!$userStore) {
|
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
goto('/login');
|
}
|
||||||
} else {
|
|
||||||
username = $userStore.username;
|
|
||||||
email = $userStore.email || '';
|
|
||||||
|
|
||||||
try {
|
onMount(async () => {
|
||||||
const res = await fetch('/api/auth/storage');
|
if (!$userStore) { goto('/login'); return; }
|
||||||
if (res.ok) {
|
username = $userStore.username;
|
||||||
storageStats = await res.json();
|
email = $userStore.email || '';
|
||||||
}
|
try {
|
||||||
} catch (err) {
|
const res = await fetch('/api/auth/storage');
|
||||||
console.error("Failed to fetch storage stats", err);
|
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() {
|
async function logout() {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
userStore.set(null);
|
userStore.set(null);
|
||||||
@@ -57,75 +145,64 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
if ((!username || username === $userStore?.username) && (!email || email === $userStore?.email)) return;
|
if (username === $userStore?.username && email === $userStore?.email) return;
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
profileError = '';
|
profileError = '';
|
||||||
profileSuccess = false;
|
profileSuccess = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/me', {
|
const res = await fetch('/api/auth/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, email })
|
body: JSON.stringify({ username, email })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
profileError = await res.text() || 'Failed to update profile';
|
||||||
profileError = text || "Failed to update profile";
|
|
||||||
} else {
|
} else {
|
||||||
const updatedUser = await res.json();
|
userStore.set(await res.json());
|
||||||
userStore.set(updatedUser);
|
|
||||||
profileSuccess = true;
|
profileSuccess = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
profileError = "Network error occurred.";
|
profileError = 'Network error occurred.';
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving = false;
|
isSaving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
passwordError = '';
|
passwordError = '';
|
||||||
passwordSuccess = false;
|
passwordSuccess = false;
|
||||||
|
if (newPassword !== confirmPassword) { passwordError = "New passwords don't match."; return; }
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
passwordError = "New passwords don't match.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSavingPassword = true;
|
isSavingPassword = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/change-password', {
|
const res = await fetch('/api/auth/change-password', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||||
current_password: currentPassword,
|
|
||||||
new_password: newPassword
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
passwordError = await res.text() || 'Failed to change password';
|
||||||
passwordError = text || "Failed to change password";
|
|
||||||
} else {
|
} else {
|
||||||
passwordSuccess = true;
|
passwordSuccess = true;
|
||||||
currentPassword = '';
|
currentPassword = '';
|
||||||
newPassword = '';
|
newPassword = '';
|
||||||
confirmPassword = '';
|
confirmPassword = '';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
passwordError = "Network error occurred.";
|
passwordError = 'Network error occurred.';
|
||||||
}
|
}
|
||||||
|
|
||||||
isSavingPassword = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Settings - TypstDrive</title>
|
<title>Settings - TypstDrive</title>
|
||||||
<meta name="description" content="Manage your TypstDrive settings." />
|
<meta name="description" content="Manage your TypstDrive settings." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col">
|
<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" />
|
<Icon icon="mdi:cog" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
||||||
Settings
|
Settings
|
||||||
</h1>
|
</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">
|
||||||
<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" />
|
||||||
<Icon icon="mdi:arrow-left" class="text-lg" />
|
Back to Dashboard
|
||||||
Back to Dashboard
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</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="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>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
<!-- Main content -->
|
||||||
<div class="p-6 sm:p-8">
|
<main class="flex-1 min-w-0 space-y-6 pb-16">
|
||||||
<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">
|
<!-- Account Section -->
|
||||||
<div class="flex items-center gap-4">
|
{#if activeSection === 'account'}
|
||||||
<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">
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||||
{$userStore?.username?.[0]?.toUpperCase() || '?'}
|
<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>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-4">Profile</h3>
|
||||||
<h3 class="text-md font-bold text-gray-900 dark:text-white">Profile</h3>
|
|
||||||
|
|
||||||
{#if profileError}
|
{#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">
|
<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>
|
||||||
{profileError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if profileSuccess}
|
{#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">
|
<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>
|
||||||
Profile successfully updated.
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div class="space-y-4">
|
||||||
<label for="username-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
<div>
|
||||||
<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" />
|
<label for="username-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||||
</div>
|
<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">
|
<div>
|
||||||
<label for="email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
|
<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" />
|
<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>
|
||||||
|
<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">
|
||||||
<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">
|
|
||||||
{#if isSaving}
|
{#if isSaving}
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||||
Saving...
|
Saving...
|
||||||
@@ -201,39 +303,32 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<h3 class="text-sm font-bold text-gray-900 dark:text-white mb-4">Change Password</h3>
|
||||||
<h3 class="text-md font-bold text-gray-900 dark:text-white">Change Password</h3>
|
|
||||||
|
|
||||||
{#if passwordError}
|
{#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">
|
<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>
|
||||||
{passwordError}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if passwordSuccess}
|
{#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">
|
<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>
|
||||||
Password successfully changed.
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div class="space-y-4">
|
||||||
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
<div>
|
||||||
<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" />
|
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
||||||
</div>
|
<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>
|
<div>
|
||||||
<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" />
|
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||||
</div>
|
<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>
|
<div>
|
||||||
<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" />
|
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
|
||||||
</div>
|
<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 class="flex justify-start mt-2">
|
</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 shadow-sm text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-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 text-sm font-semibold transition-colors disabled:opacity-50 flex items-center gap-2">
|
||||||
{#if isSavingPassword}
|
{#if isSavingPassword}
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
||||||
Updating...
|
Updating...
|
||||||
@@ -244,83 +339,226 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
<!-- Theme Section -->
|
||||||
|
{#if activeSection === 'theme'}
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||||
<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">
|
<div class="p-6 sm:p-8">
|
||||||
{#if isSaving}
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||||
<Icon icon="mdi:loading" class="animate-spin text-lg" />
|
<Icon icon="mdi:palette-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||||
Saving...
|
Theme Settings
|
||||||
{:else}
|
</h2>
|
||||||
<Icon icon="mdi:content-save" class="text-lg" />
|
<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>
|
||||||
Save Changes
|
<ThemePicker />
|
||||||
{/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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Storage Section -->
|
||||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
{#if activeSection === 'storage'}
|
||||||
<div class="p-6 sm:p-8">
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
<div class="p-6 sm:p-8">
|
||||||
<Icon icon="mdi:palette-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||||
Theme Settings
|
<Icon icon="mdi:harddisk" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||||
</h2>
|
Storage
|
||||||
|
</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-4 flex justify-between items-end">
|
||||||
<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>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Total Space Used</span>
|
||||||
<ThemePicker />
|
<span class="text-sm font-bold text-gray-900 dark:text-white">
|
||||||
</div>
|
{storageStats ? formatBytes(storageStats.total_size_bytes) : 'Loading...'}
|
||||||
</div>
|
</span>
|
||||||
</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>
|
|
||||||
</div>
|
</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="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div class="w-3 h-3 rounded-full bg-purple-500"></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>
|
<div class="w-3 h-3 rounded-full bg-blue-500 flex-shrink-0"></div>
|
||||||
<p class="text-gray-500 dark:text-gray-400">Images & Assets</p>
|
<div>
|
||||||
<p class="font-semibold text-gray-900 dark:text-white">
|
<p class="text-gray-500 dark:text-gray-400 text-xs">Documents</p>
|
||||||
{storageStats ? formatBytes(storageStats.files_size_bytes) : '...'}
|
<p class="font-semibold text-gray-900 dark:text-white">
|
||||||
</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
|
||||||
|
<!-- 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 />
|
<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