A focused Docker Compose management web application.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: users page

Brooke 2ea4f7e6 65b91752

+508 -76
-40
Cargo.lock
··· 582 582 ] 583 583 584 584 [[package]] 585 - name = "convert_case" 586 - version = "0.8.0" 587 - source = "registry+https://github.com/rust-lang/crates.io-index" 588 - checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" 589 - dependencies = [ 590 - "unicode-segmentation", 591 - ] 592 - 593 - [[package]] 594 585 name = "cookie" 595 586 version = "0.18.1" 596 587 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1870 1861 "json-patch", 1871 1862 "log", 1872 1863 "luminary-macros", 1873 - "multi_index_map", 1874 1864 "password-auth", 1875 1865 "rand_chacha 0.3.1", 1876 1866 "salvo", ··· 1967 1957 "mime", 1968 1958 "spin", 1969 1959 "version_check", 1970 - ] 1971 - 1972 - [[package]] 1973 - name = "multi_index_map" 1974 - version = "0.15.1" 1975 - source = "registry+https://github.com/rust-lang/crates.io-index" 1976 - checksum = "7a6c1f22ca32737a420c91da81efce8e17678eadbe3878d860bee879c0d2c593" 1977 - dependencies = [ 1978 - "multi_index_map_derive", 1979 - "rustc-hash", 1980 - "slab", 1981 - ] 1982 - 1983 - [[package]] 1984 - name = "multi_index_map_derive" 1985 - version = "0.15.1" 1986 - source = "registry+https://github.com/rust-lang/crates.io-index" 1987 - checksum = "12a678f8fa5da39f72d16380791208f8924d3b9f6c5cc5e2a85a62a927402851" 1988 - dependencies = [ 1989 - "convert_case", 1990 - "proc-macro-error2", 1991 - "proc-macro2", 1992 - "quote", 1993 - "syn 1.0.109", 1994 1960 ] 1995 1961 1996 1962 [[package]] ··· 4103 4069 version = "0.1.4" 4104 4070 source = "registry+https://github.com/rust-lang/crates.io-index" 4105 4071 checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" 4106 - 4107 - [[package]] 4108 - name = "unicode-segmentation" 4109 - version = "1.12.0" 4110 - source = "registry+https://github.com/rust-lang/crates.io-index" 4111 - checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 4112 4072 4113 4073 [[package]] 4114 4074 name = "unicode-width"
+38
packages/node/.sqlx/query-33d3c6ee3e78a5099c5671dfd6f128b2f786151c218b5d2f01db4c416dcd5a6a.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT * FROM [user] WHERE [reset_token] = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "uuid", 8 + "ordinal": 0, 9 + "type_info": "Text" 10 + }, 11 + { 12 + "name": "username", 13 + "ordinal": 1, 14 + "type_info": "Text" 15 + }, 16 + { 17 + "name": "password", 18 + "ordinal": 2, 19 + "type_info": "Text" 20 + }, 21 + { 22 + "name": "reset_token", 23 + "ordinal": 3, 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Right": 1 29 + }, 30 + "nullable": [ 31 + false, 32 + false, 33 + true, 34 + true 35 + ] 36 + }, 37 + "hash": "33d3c6ee3e78a5099c5671dfd6f128b2f786151c218b5d2f01db4c416dcd5a6a" 38 + }
+1 -1
packages/node/.sqlx/query-3dffe540f6323cca6f6a98668e4576727b3c837fe0f25832b9548f402a4e5549.json
··· 19 19 "type_info": "Text" 20 20 }, 21 21 { 22 - "name": "join_token", 22 + "name": "reset_token", 23 23 "ordinal": 3, 24 24 "type_info": "Text" 25 25 }
-12
packages/node/.sqlx/query-40ff407deaa3bad99cbb27b3640dff0bada0659ac8ef3b662c7befb4cb481b8a.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "INSERT INTO [user] ([uuid], [username], [join_token]) VALUES (?, ?, ?)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 3 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "40ff407deaa3bad99cbb27b3640dff0bada0659ac8ef3b662c7befb4cb481b8a" 12 - }
+1 -1
packages/node/.sqlx/query-59a97bc1bac68db673fe258f28078370d3bfa5374a71d8b9f465a224ecb5ad17.json
··· 19 19 "type_info": "Text" 20 20 }, 21 21 { 22 - "name": "join_token", 22 + "name": "reset_token", 23 23 "ordinal": 3, 24 24 "type_info": "Text" 25 25 }
+38
packages/node/.sqlx/query-9c7b60e27f5ec596ad3182eee5411b0c236373c1a344acde2cf0d11a7df0bd5c.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT * FROM [user]", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "uuid", 8 + "ordinal": 0, 9 + "type_info": "Text" 10 + }, 11 + { 12 + "name": "username", 13 + "ordinal": 1, 14 + "type_info": "Text" 15 + }, 16 + { 17 + "name": "password", 18 + "ordinal": 2, 19 + "type_info": "Text" 20 + }, 21 + { 22 + "name": "reset_token", 23 + "ordinal": 3, 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Right": 0 29 + }, 30 + "nullable": [ 31 + false, 32 + false, 33 + true, 34 + true 35 + ] 36 + }, 37 + "hash": "9c7b60e27f5ec596ad3182eee5411b0c236373c1a344acde2cf0d11a7df0bd5c" 38 + }
+12
packages/node/.sqlx/query-9fa7091502825d7477c80484a709d74d03a1a54c67083fd3b34f7fac5cd96896.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "INSERT INTO [user] ([uuid], [username], [reset_token]) VALUES (?, ?, ?)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 3 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "9fa7091502825d7477c80484a709d74d03a1a54c67083fd3b34f7fac5cd96896" 12 + }
+12
packages/node/.sqlx/query-a7f6c56dcb894f10f2d47dbf45a42baf8ef4fdce1d2c81c82413816061727f52.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "UPDATE [user] SET [password] = ?, [reset_token] = NULL WHERE [uuid] = ?", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 2 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "a7f6c56dcb894f10f2d47dbf45a42baf8ef4fdce1d2c81c82413816061727f52" 12 + }
+12
packages/node/.sqlx/query-a93e15da9705e3d9fb65a1625b12bf25d0f5be68d5244d2bc4a677ee4abde211.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "DELETE FROM [user] WHERE [uuid] = ?", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 1 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "a93e15da9705e3d9fb65a1625b12bf25d0f5be68d5244d2bc4a677ee4abde211" 12 + }
+1 -2
packages/node/Cargo.toml
··· 16 16 json-patch = "4.1.0" 17 17 serde-saphyr = "0.0" 18 18 serde_json = "1.0" 19 + tracing = "0.1.44" 19 20 dotenvy = "0.15" 20 21 base64 = "0.22" 21 22 log = "0.4.29" ··· 46 47 "migrate", 47 48 "uuid", 48 49 ] } 49 - multi_index_map = "0.15.1" 50 - tracing = "0.1.44"
+1 -1
packages/node/migrations/20260223001530_init.sql
··· 4 4 [username] TEXT NOT NULL UNIQUE, 5 5 -- Password will be null for users that have been invited but haven't set a password yet 6 6 [password] TEXT NULL, 7 - [join_token] TEXT NULL UNIQUE, 7 + [reset_token] TEXT NULL UNIQUE, 8 8 PRIMARY KEY ([uuid]) 9 9 ); 10 10
+166 -14
packages/node/src/api/auth.rs
··· 4 4 5 5 use eyre::{Context, ContextCompat, Result}; 6 6 use log::error; 7 + use luminary_macros::wrap_err; 7 8 use password_auth::verify_password; 8 9 use rand_chacha::{ 9 10 ChaCha12Rng, 10 11 rand_core::{RngCore, SeedableRng}, 11 12 }; 12 - use salvo::{oapi::extract::JsonBody, prelude::*}; 13 - use serde::Deserialize; 13 + use salvo::{ 14 + oapi::extract::{JsonBody, PathParam}, 15 + prelude::*, 16 + }; 17 + use serde::{Deserialize, Serialize}; 14 18 use sqlx::{SqlitePool, prelude::FromRow}; 15 19 use uuid::Uuid; 16 20 ··· 20 24 pub fn router() -> Router { 21 25 return Router::with_path("/auth") 22 26 .push(Router::with_path("login").post(login)) 23 - .push(Router::with_path("logout").post(logout)); 27 + .push(Router::with_path("logout").post(logout)) 28 + .push( 29 + Router::with_path("reset/{token}") 30 + .get(verify_reset_token) 31 + .post(reset_password), 32 + ) 33 + .push( 34 + Router::with_hoop(protected).push( 35 + Router::with_path("users") 36 + .get(get_users) 37 + .post(create_user) 38 + .push(Router::with_path("{user}").delete(delete_user)), 39 + ), 40 + ); 24 41 } 25 42 26 43 /// Reads username and password from the request body, and returns an authentication token if the credentials are valid. ··· 39 56 #[endpoint] 40 57 async fn logout(req: &mut Request, depot: &mut Depot) -> LuminaryResponse<()> { 41 58 let auth = obtain!(depot, LuminaryAuthentication); 42 - 43 59 let token = extract_token(req).wrap_err("Missing or invalid authorization token")?; 44 60 45 61 auth.logout(token).await?; 46 62 return Ok(().into()); 47 63 } 48 64 65 + /// Fetches a list of all users. 66 + #[endpoint] 67 + async fn get_users(depot: &mut Depot) -> LuminaryResponse<Vec<LuminaryUser>> { 68 + let auth = obtain!(depot, LuminaryAuthentication); 69 + let users = auth.get_users().await?; 70 + return Ok(users.into()); 71 + } 72 + 73 + /// Creates a new user with the given username, returning a reset token that can be used to set the user's password. 74 + #[endpoint] 75 + async fn create_user(depot: &mut Depot, body: JsonBody<CreateUserRequest>) -> LuminaryResponse<String> { 76 + let auth = obtain!(depot, LuminaryAuthentication); 77 + 78 + let reset_token = auth.create_user(&body.username).await?; 79 + return Ok(reset_token.into()); 80 + } 81 + 82 + /// Request body for creating a new user. 83 + #[derive(Debug, Deserialize, ToSchema)] 84 + pub struct CreateUserRequest { 85 + username: String, 86 + } 87 + 88 + /// Deletes the user with the given UUID. 89 + #[endpoint] 90 + async fn delete_user(depot: &mut Depot, user: PathParam<String>) -> LuminaryResponse<()> { 91 + let auth = obtain!(depot, LuminaryAuthentication); 92 + 93 + auth.delete_user(&user).await?; 94 + return Ok(().into()); 95 + } 96 + 97 + /// Verifies that a reset token is valid. 98 + #[endpoint] 99 + async fn verify_reset_token(depot: &mut Depot, token: PathParam<String>) -> LuminaryResponse<String> { 100 + let auth = obtain!(depot, LuminaryAuthentication); 101 + 102 + let user = auth 103 + .get_user_from_reset_token(&token) 104 + .await? 105 + .wrap_err("Invalid reset token")?; 106 + 107 + return Ok(user.username.into()); 108 + } 109 + 110 + /// Resets a user's password using a reset token, which is invalidated after use. 111 + #[endpoint] 112 + async fn reset_password( 113 + depot: &mut Depot, 114 + token: PathParam<String>, 115 + body: JsonBody<ResetPasswordRequest>, 116 + ) -> LuminaryResponse<()> { 117 + let auth = obtain!(depot, LuminaryAuthentication); 118 + 119 + let user = auth 120 + .get_user_from_reset_token(&token) 121 + .await? 122 + .wrap_err("Invalid reset token")?; 123 + 124 + auth.set_password(&user.uuid, body.password.clone()).await?; 125 + return Ok(().into()); 126 + } 127 + 128 + /// Request body for resetting a user's password. 129 + #[derive(Debug, Deserialize, ToSchema)] 130 + pub struct ResetPasswordRequest { 131 + password: String, 132 + } 133 + 49 134 /// Acts as the authentication backend for the Luminary Node, handling user authentication and bearer token management. 50 135 #[derive(Debug, Clone)] 51 136 pub struct LuminaryAuthentication { ··· 58 143 Self { pool } 59 144 } 60 145 61 - /// Authenticates a user with the given credentials and returns a bearer token on success. 62 - pub async fn login(&self, credentials: LuminaryUserCredentials) -> Result<Option<String>> { 146 + pub async fn verify_password( 147 + &self, 148 + credentials: LuminaryUserCredentials, 149 + ) -> Result<Option<LuminaryUser>> { 63 150 let user = sqlx::query_as!( 64 151 LuminaryUser, 65 152 "SELECT * FROM [user] WHERE [username] = ?", ··· 80 167 .await 81 168 .wrap_err("Password verification task failed")?; 82 169 170 + return Ok(user); 171 + } 172 + 173 + /// Authenticates a user with the given credentials and returns a bearer token on success. 174 + pub async fn login(&self, credentials: LuminaryUserCredentials) -> Result<Option<String>> { 83 175 // Terminate early if the user doesn't exist or the password is wrong 84 - let user = match user { 176 + let user = match self.verify_password(credentials).await? { 85 177 None => return Ok(None), 86 178 Some(u) => u, 87 179 }; ··· 127 219 return Ok(user); 128 220 } 129 221 130 - async fn create_user(&self, username: &str) -> Result<String> { 222 + /// Creates a new user with the given username and a random reset token, returning the reset token. 223 + #[wrap_err("Failed to create user")] 224 + pub async fn create_user(&self, username: &str) -> Result<String> { 131 225 let uuid = Uuid::new_v4().to_string(); 132 226 let token = generate_token(); 133 227 134 228 sqlx::query!( 135 - "INSERT INTO [user] ([uuid], [username], [join_token]) VALUES (?, ?, ?)", 229 + "INSERT INTO [user] ([uuid], [username], [reset_token]) VALUES (?, ?, ?)", 136 230 uuid, 137 231 username, 138 232 token ··· 143 237 144 238 return Ok(token); 145 239 } 240 + 241 + /// Sets a user's password to the given value. 242 + #[wrap_err("Failed to update password")] 243 + pub async fn set_password(&self, uuid: &str, password: String) -> Result<()> { 244 + let hashed_password = tokio::task::spawn_blocking(move || password_auth::generate_hash(password)) 245 + .await 246 + .wrap_err("Failed to spawn hashing task")?; 247 + 248 + sqlx::query!( 249 + "UPDATE [user] SET [password] = ?, [reset_token] = NULL WHERE [uuid] = ?", 250 + hashed_password, 251 + uuid 252 + ) 253 + .execute(&self.pool) 254 + .await 255 + .wrap_err("Failed to set user password")?; 256 + 257 + return Ok(()); 258 + } 259 + 260 + /// Finds a user from their reset token, or [None] if the token is invalid. 261 + #[wrap_err("Failed to get user from reset token")] 262 + pub async fn get_user_from_reset_token(&self, token: &str) -> Result<Option<LuminaryUser>> { 263 + let user = sqlx::query_as!( 264 + LuminaryUser, 265 + "SELECT * FROM [user] WHERE [reset_token] = ?", 266 + token 267 + ) 268 + .fetch_optional(&self.pool) 269 + .await 270 + .wrap_err("Failed to look up user from reset token")?; 271 + 272 + return Ok(user); 273 + } 274 + 275 + /// Deletes a user from the database, along with all their sessions. 276 + #[wrap_err("Failed to delete user")] 277 + pub async fn delete_user(&self, uuid: &str) -> Result<()> { 278 + sqlx::query!("DELETE FROM [user] WHERE [uuid] = ?", uuid) 279 + .execute(&self.pool) 280 + .await 281 + .wrap_err("Failed to delete user")?; 282 + 283 + return Ok(()); 284 + } 285 + 286 + /// Fetches a list of all users. 287 + #[wrap_err("Failed to get users")] 288 + pub async fn get_users(&self) -> Result<Vec<LuminaryUser>> { 289 + let users = sqlx::query_as!(LuminaryUser, "SELECT * FROM [user]") 290 + .fetch_all(&self.pool) 291 + .await 292 + .wrap_err("Failed to query users")?; 293 + 294 + return Ok(users); 295 + } 146 296 } 147 297 148 298 /// Salvo middleware for validating authentication. ··· 152 302 /// # Examples 153 303 /// ``` 154 304 /// use salvo::{Router, oapi::endpoint}; 155 - /// use crate::auth::protected; 305 + /// use crate::api::auth::protected; 156 306 /// 157 307 /// let router = Router::new().hoop(protected).get(protected_handler); 158 308 /// ··· 205 355 } 206 356 207 357 /// Represents a user in Luminary Node. 208 - #[derive(Clone, FromRow)] 358 + #[derive(Clone, Serialize, ToSchema, FromRow)] 209 359 pub struct LuminaryUser { 210 360 uuid: String, 211 361 username: String, 362 + #[serde(skip_serializing)] 212 363 password: Option<String>, 213 - join_token: Option<String>, 364 + #[serde(skip_serializing)] 365 + reset_token: Option<String>, 214 366 } 215 367 216 368 impl Debug for LuminaryUser { ··· 220 372 .field("username", &self.username) 221 373 .field("password", &"***") 222 374 .field( 223 - "join_token", 224 - if self.join_token.is_none() { 375 + "reset_token", 376 + if self.reset_token.is_none() { 225 377 &"None" 226 378 } else { 227 379 &"Some(***)"
+1 -2
packages/node/src/api/mod.rs
··· 67 67 .push(auth::router()) 68 68 .push( 69 69 // New router for protected routes, to avoid repetition 70 - Router::new() 71 - .hoop(protected) 70 + Router::with_hoop(protected) 72 71 .push(Router::with_path("realtime").get(app_subscribe)) 73 72 .push( 74 73 Router::with_path("/project/{project}")
+70
packages/panel/src/lib/component/CopyBox.svelte
··· 1 + <script lang="ts"> 2 + import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; 3 + import type { Attachment } from "svelte/attachments"; 4 + import Tooltip from "./Tooltip.svelte"; 5 + import { sleep } from "$lib"; 6 + import Fa from "svelte-fa"; 7 + 8 + let { 9 + value, 10 + }: { 11 + value: string; 12 + } = $props(); 13 + 14 + let copied = $state(false); 15 + 16 + async function copy() { 17 + if (copied) return; 18 + 19 + await navigator.clipboard.writeText(value); 20 + copied = true; 21 + 22 + await sleep(1000); 23 + 24 + copied = false; 25 + } 26 + 27 + const select: Attachment<HTMLInputElement> = (el) => { 28 + const select = () => el.select(); 29 + el.addEventListener("click", select); 30 + el.addEventListener("focusin", select); 31 + 32 + () => { 33 + el.removeEventListener("click", select); 34 + el.removeEventListener("focusin", select); 35 + }; 36 + }; 37 + </script> 38 + 39 + <div class="container"> 40 + <input type="text" readonly {value} aria-label="Copy text" {@attach select} /> 41 + <Tooltip content={copied ? "Copied!" : "Copy to clipboard"}> 42 + <button type="button" onclick={copy}> 43 + <Fa icon={copied ? faCheck : faCopy} /> 44 + </button> 45 + </Tooltip> 46 + </div> 47 + 48 + <style lang="scss"> 49 + .container { 50 + display: flex; 51 + gap: 5px; 52 + 53 + width: fit-content; 54 + 55 + background-color: var(--surface1); 56 + border-radius: 10px; 57 + 58 + display: flex; 59 + align-items: center; 60 + 61 + input { 62 + outline: none !important; 63 + } 64 + 65 + button { 66 + background: none; 67 + color: inherit; 68 + } 69 + } 70 + </style>
+3 -1
packages/panel/src/lib/component/LoaderButton.svelte
··· 11 11 "aria-label": ariaLabel, 12 12 style = "button", 13 13 loading = false, 14 + fit = false, 14 15 children, 15 16 disabled, 16 17 onclick, ··· 21 22 "aria-label"?: string; 22 23 disabled?: boolean; 23 24 loading?: boolean; 25 + fit?: boolean; 24 26 } = $props(); 25 27 </script> 26 28 27 - <button class="full {style}" disabled={loading || disabled} {onclick} aria-label={ariaLabel}> 29 + <button class="{fit ? 'fit' : 'full'} {style}" disabled={loading || disabled} {onclick} aria-label={ariaLabel}> 28 30 {#if loading} 29 31 <span class="loader"></span> 30 32 {/if}
+11 -1
packages/panel/src/lib/component/PromiseButton.svelte
··· 33 33 disabled, 34 34 loading, 35 35 style, 36 + fit, 36 37 }: { 37 38 style?: ComponentProps<typeof LoaderButton>["style"]; 38 39 children: Snippet<[boolean]> | string; ··· 40 41 "aria-label"?: string; 41 42 disabled?: boolean; 42 43 loading?: boolean; 44 + fit?: boolean; 43 45 } = $props(); 44 46 45 47 let waiting = $state(false); ··· 54 56 } 55 57 </script> 56 58 57 - <LoaderButton onclick={handleClick} {disabled} {style} {children} loading={waiting || loading} aria-label={ariaLabel} /> 59 + <LoaderButton 60 + loading={waiting || loading} 61 + aria-label={ariaLabel} 62 + onclick={handleClick} 63 + {disabled} 64 + {children} 65 + {style} 66 + {fit} 67 + />
+1 -1
packages/panel/src/lib/component/Tooltip.svelte
··· 22 22 {@render children()} 23 23 </div> 24 24 25 - <div {...tooltip.content} class="tooltip"> 25 + <div {...tooltip.content} class="tooltip fit"> 26 26 <div class="arrow" {...tooltip.arrow}></div> 27 27 <span>{content}</span> 28 28 </div>
+11
packages/panel/src/routes/(authenticated)/admin/+page.svelte
··· 1 + <script lang="ts"> 2 + import { faUsers } from "@fortawesome/free-solid-svg-icons"; 3 + import Tabs from "$lib/component/Tabs.svelte"; 4 + import UserList from "./UserList.svelte"; 5 + </script> 6 + 7 + <Tabs tabs={[{ icon: faUsers, label: "users", content: users }]} /> 8 + 9 + {#snippet users()} 10 + <UserList /> 11 + {/snippet}
+129
packages/panel/src/routes/(authenticated)/admin/UserList.svelte
··· 1 + <script lang="ts"> 2 + import { faBan, faKey, faPlus, faSignature, faUserCircle, faWrench } from "@fortawesome/free-solid-svg-icons"; 3 + import { api, isMobile, openDialog } from "$lib"; 4 + import Fa from "svelte-fa"; 5 + import LoaderButton from "$lib/component/LoaderButton.svelte"; 6 + import CopyBox from "$lib/component/CopyBox.svelte"; 7 + import { page } from "$app/state"; 8 + 9 + type LuminaryUser = api.components["schemas"]["luminary_node.api.auth.LuminaryUser"]; 10 + 11 + let users: LuminaryUser[] = $state([]); 12 + 13 + async function refresh() { 14 + users = await api.client.GET("/api/auth/users").then((res) => res.data!); 15 + } 16 + 17 + let username = $state(""); 18 + let loading = $state(false); 19 + async function create_user() { 20 + loading = true; 21 + const response = await api.client.POST("/api/auth/users", { 22 + body: { username }, 23 + }); 24 + 25 + let url = new URL(`reset/${response.data!}`, window.location.toString()).toString(); 26 + 27 + refresh(); 28 + openDialog({ 29 + content: success, 30 + title: `User ${username} created!`, 31 + parameters: { username, url }, 32 + }); 33 + loading = false; 34 + } 35 + 36 + async function delete_user(uuid: string) { 37 + await api.client.DELETE("/api/auth/users/{user}", { params: { path: { user: uuid } } }); 38 + refresh(); 39 + } 40 + 41 + refresh(); 42 + </script> 43 + 44 + {#snippet create()} 45 + <p>This form will create a new Luminary user with the given username.</p> 46 + <p>The next page will show a reset password link to set up the account.</p> 47 + <form class="flexc gap-20" onsubmit={create_user}> 48 + <div> 49 + <label for="username">Username</label> 50 + <input required minlength="1" id="username" type="text" bind:value={username} /> 51 + </div> 52 + <LoaderButton {loading} fit> 53 + {#snippet children(loading)} 54 + {#if loading} 55 + Creating user... 56 + {:else} 57 + Create user 58 + {/if} 59 + {/snippet} 60 + </LoaderButton> 61 + </form> 62 + {/snippet} 63 + 64 + {#snippet success({ username, url }: { username: string; url: string })} 65 + <p>Send {username} the link below so that they can set up their account:</p> 66 + <CopyBox value={url} /> 67 + {/snippet} 68 + 69 + <table> 70 + <thead> 71 + <tr> 72 + <th></th> 73 + {#if !isMobile()} 74 + <th><Fa icon={faKey} /> uuid</th> 75 + {/if} 76 + <th><Fa icon={faSignature} /> username</th> 77 + <th class="actions-col"><Fa icon={faWrench} /> actions</th> 78 + </tr> 79 + </thead> 80 + <tbody> 81 + {#each users as user} 82 + <tr> 83 + <td><Fa icon={faUserCircle} /></td> 84 + {#if !isMobile()} 85 + <td class="subtext">{user.uuid}</td> 86 + {/if} 87 + <td>{user.username}</td> 88 + <td class="actions-col"> 89 + <button class="outline" onclick={() => delete_user(user.uuid)}> 90 + <Fa icon={faBan} /> Delete 91 + </button> 92 + </td> 93 + </tr> 94 + {/each} 95 + </tbody> 96 + </table> 97 + 98 + <div class="flexr gap-10"> 99 + <button 100 + class="outline flexr gap-5 center" 101 + onclick={() => { 102 + openDialog({ title: "Create User", content: create }); 103 + }} 104 + > 105 + <Fa icon={faPlus} /> Create user 106 + </button> 107 + </div> 108 + 109 + <style lang="scss"> 110 + table { 111 + border-collapse: collapse; 112 + } 113 + 114 + thead th { 115 + border-bottom: 1px solid var(--subtext0); 116 + } 117 + 118 + th, 119 + td { 120 + text-align: left; 121 + white-space: nowrap; 122 + padding: 5px 10px; 123 + } 124 + 125 + .actions-col { 126 + text-align: right; 127 + width: 100%; 128 + } 129 + </style>