From 67a824d4ea2eec87c09fdf016ee31cb252ef4585 Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Fri, 2 May 2025 11:30:09 -0700 Subject: [PATCH 01/14] feat(backend/ws): Send `user_name` in `UserConnected` --- backend/src/ws/handlers.rs | 1 + backend/src/ws/messages.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/src/ws/handlers.rs b/backend/src/ws/handlers.rs index 891a71d..4334f59 100644 --- a/backend/src/ws/handlers.rs +++ b/backend/src/ws/handlers.rs @@ -31,6 +31,7 @@ impl RgWebsocket { _ = project.broadcast.send(ServerMessage::UserConnected { user_id: self.user_info.id.clone(), + user_name: self.user_info.name.clone(), }); let files = project.get_files().clone(); diff --git a/backend/src/ws/messages.rs b/backend/src/ws/messages.rs index 0f37983..9d4fd8e 100644 --- a/backend/src/ws/messages.rs +++ b/backend/src/ws/messages.rs @@ -47,6 +47,7 @@ pub enum ServerMessage { }, UserConnected { user_id: String, + user_name: String, }, Sync { file: String, From b99d7e1872d3246896ec18895afb13e5a44fd236 Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Fri, 2 May 2025 13:28:20 -0700 Subject: [PATCH 02/14] feat(backend): Store usernames in global state --- backend/src/auth/routes.rs | 14 +++++++++++++- backend/src/main.rs | 2 ++ backend/src/project/routes.rs | 14 +++++++++++++- backend/src/state.rs | 20 ++++++++++++++++++++ backend/src/ws/handlers.rs | 10 +++++++++- backend/src/ws/messages.rs | 2 +- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/backend/src/auth/routes.rs b/backend/src/auth/routes.rs index 59af58d..1fa55e2 100644 --- a/backend/src/auth/routes.rs +++ b/backend/src/auth/routes.rs @@ -7,6 +7,7 @@ use uuid::Uuid; use crate::auth::jwt::RgUserData; use crate::auth::{github, jwt}; use crate::http_errors::HttpErrors; +use crate::state::AppState; pub struct OAuthData { pub client: oauth2::basic::BasicClient, @@ -39,6 +40,7 @@ pub async fn me(req: HttpRequest) -> HttpResult { #[get("/auth/callback")] async fn callback( + state: web::Data, query: web::Query, oauth_data: web::Data, ) -> HttpResult { @@ -62,6 +64,8 @@ async fn callback( let jwt = RgUserData::new(github_user.login.clone(), github_user.login.clone(), false).encode()?; + state.add_username(github_user.login.clone(), github_user.login.clone()); + Ok(HttpResponse::Ok().json(serde_json::json!({ "jwt": jwt, "id": github_user.login, @@ -77,11 +81,16 @@ struct GuestLoginRequest { } #[proof_route(post("/auth/guest"))] -async fn login_guest(body: web::Json) -> HttpResult { +async fn login_guest( + state: web::Data, + body: web::Json, +) -> HttpResult { let guest_name = &body.guest_name; let guest_uuid = Uuid::new_v4().to_string(); let jwt = RgUserData::new(guest_uuid.clone(), guest_name.clone(), true).encode()?; + state.add_username(guest_uuid.clone(), guest_name.clone()); + Ok(HttpResponse::Ok().json(serde_json::json!({ "jwt": jwt, "id": guest_uuid, @@ -96,6 +105,7 @@ struct UpdateNameRequest { #[proof_route(post("/auth/update"))] async fn update_name( + state: web::Data, body: web::Json, req: actix_web::HttpRequest, ) -> HttpResult { @@ -110,6 +120,8 @@ async fn update_name( let jwt = RgUserData::new(uuid.clone(), new_name.clone(), true).encode()?; + state.add_username(uuid.clone(), new_name.clone()); + Ok(HttpResponse::Ok().json(serde_json::json!({ "jwt": jwt, "id": uuid, diff --git a/backend/src/main.rs b/backend/src/main.rs index 87ded58..f11d123 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -7,6 +7,7 @@ mod state; mod utils; mod ws; +use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; use actix_cors::Cors; @@ -35,6 +36,7 @@ async fn main() -> std::io::Result<()> { let app_state = web::Data::new(AppState { manager: Mutex::new(ProjectManager::new()).into(), + usernames: Mutex::new(HashMap::new()).into(), }); HttpServer::new(move || { diff --git a/backend/src/project/routes.rs b/backend/src/project/routes.rs index 38e1dfd..7b73b11 100644 --- a/backend/src/project/routes.rs +++ b/backend/src/project/routes.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use actix_error_proc::{proof_route, HttpResult}; use actix_web::{web, HttpRequest, HttpResponse}; use serde_json::json; @@ -36,12 +38,22 @@ pub async fn get_project( }))); } + let users = project + .allowed_users + .iter() + .filter_map(|(user, access)| { + app_state + .get_username(&user) + .map(|username| (user, (username, access))) + }) + .collect::>(); + Ok(HttpResponse::Ok().json(json!({ "access": access, "id": project.id, "name": project.name, "owner": project.owner, - "allowed_users": project.allowed_users, + "users": users, "is_public": project.is_public, "password": project.password }))) diff --git a/backend/src/state.rs b/backend/src/state.rs index aa1b46b..203b2b2 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::{Arc, Mutex, MutexGuard}; use crate::project::ProjectManager; @@ -5,6 +6,7 @@ use crate::project::ProjectManager; #[derive(Clone)] pub struct AppState { pub manager: Arc>, + pub usernames: Arc>>, } impl AppState { @@ -12,4 +14,22 @@ impl AppState { pub fn get_manager(&self) -> MutexGuard<'_, ProjectManager> { self.manager.lock().unwrap_or_else(|e| e.into_inner()) } + + /// Get username of user with provided id + pub fn get_username(&self, id: &String) -> Option { + self.usernames + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(id) + .cloned() + } + + /// Insert username of user with provided id + pub fn add_username(&self, id: String, username: String) { + log::trace!("New username registered: {id:?} = {username:?}"); + self.usernames + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id, username); + } } diff --git a/backend/src/ws/handlers.rs b/backend/src/ws/handlers.rs index 4334f59..4a2469e 100644 --- a/backend/src/ws/handlers.rs +++ b/backend/src/ws/handlers.rs @@ -35,7 +35,15 @@ impl RgWebsocket { }); let files = project.get_files().clone(); - let users = project.allowed_users.clone(); + let users = project + .allowed_users + .iter() + .filter_map(|(user, access)| { + self.app_state + .get_username(&user) + .map(|username| (user.clone(), (username, *access))) + }) + .collect(); Ok(ServerMessage::Welcome { session_id: self.session_id.clone(), diff --git a/backend/src/ws/messages.rs b/backend/src/ws/messages.rs index 9d4fd8e..108ab68 100644 --- a/backend/src/ws/messages.rs +++ b/backend/src/ws/messages.rs @@ -61,7 +61,7 @@ pub enum ServerMessage { Welcome { session_id: String, files: HashMap, - users: HashMap, + users: HashMap, }, } From f352ff2471e21a9f038787c8732431c6260f2c23 Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Fri, 2 May 2025 19:10:58 -0700 Subject: [PATCH 03/14] feat(backend/ws): Add project config update --- backend/src/ws/handlers.rs | 35 +++++++++++++++++++++++++++++++++++ backend/src/ws/messages.rs | 16 +++++++++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/backend/src/ws/handlers.rs b/backend/src/ws/handlers.rs index 4a2469e..0ebfb7c 100644 --- a/backend/src/ws/handlers.rs +++ b/backend/src/ws/handlers.rs @@ -113,6 +113,41 @@ impl RgWebsocket { let mut manager = self.app_state.get_manager(); match msg { + ClientMessage::Config { + name, + is_public, + password, + } => { + let project = manager.get_project_mut(self.project_id)?; + + if project.owner != self.user_info.id { + return Err(ServerMessageError::None); + } + + if let Some(name) = name { + project.name = name; + } + + if let Some(is_public) = is_public { + project.is_public = is_public; + } + + if let Some(password) = password { + if password.is_empty() { + project.password = None; + } else { + project.password = project.is_public.then_some(password); + } + } + + _ = project.broadcast.send(ServerMessage::ProjectConfig { + name: project.name.clone(), + is_public: project.is_public, + password: project.password.clone(), + }); + + Err(ServerMessageError::None) + } ClientMessage::FileCreate { file } => { self.access.need_editor()?; diff --git a/backend/src/ws/messages.rs b/backend/src/ws/messages.rs index 108ab68..8f5e5ee 100644 --- a/backend/src/ws/messages.rs +++ b/backend/src/ws/messages.rs @@ -9,9 +9,10 @@ use crate::project::AccessLevel; #[derive(Serialize, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum ClientMessage { - PermitAccess { - user_id: String, - access: AccessLevel, + Config { + name: Option, + is_public: Option, + password: Option, }, FileCreate { file: String, @@ -19,6 +20,10 @@ pub enum ClientMessage { FileDelete { file: String, }, + PermitAccess { + user_id: String, + access: AccessLevel, + }, Sync { file: String, revision: usize, @@ -37,6 +42,11 @@ pub enum ServerMessage { Error { message: String, }, + ProjectConfig { + name: String, + is_public: bool, + password: Option, + }, ProjectFiles { /// List of all file paths files: HashMap, From 5cefc102380a8e7ae67ecb9fd51b8b80dec27d6d Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Fri, 2 May 2025 20:02:59 -0700 Subject: [PATCH 04/14] feat(frontend/colab): Implement some colab thingy thingy - Permissions - Config update --- .../src/components/SelectField.module.sass | 2 +- frontend/src/components/SelectField.tsx | 15 +- .../src/features/colab/services/project.ts | 29 +++- frontend/src/features/colab/stores/project.ts | 2 + .../src/features/colab/types/ProjectInfo.ts | 2 +- .../colab/utils/interceptProjectRoutes.ts | 27 ++-- .../features/colab/views/Colab.module.sass | 10 +- frontend/src/features/colab/views/Colab.tsx | 128 +++++++++++++++--- frontend/src/features/ws/types/client.d.ts | 7 + frontend/src/features/ws/types/server.d.ts | 7 + 10 files changed, 191 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/SelectField.module.sass b/frontend/src/components/SelectField.module.sass index 950e3dc..1c69c59 100644 --- a/frontend/src/components/SelectField.module.sass +++ b/frontend/src/components/SelectField.module.sass @@ -8,7 +8,7 @@ color: var(--surface2-foreground) font-size: .8rem - &:hover + &:where(:not(.disabled)):hover filter: brightness(0.8) > span diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx index 233eedc..ae024f8 100644 --- a/frontend/src/components/SelectField.tsx +++ b/frontend/src/components/SelectField.tsx @@ -26,6 +26,11 @@ export type SelectFieldProps = */ closeOnChange?: boolean; + /** + * Whether or not is editable + */ + disabled?: boolean; + /** * Position of the floating options */ @@ -85,13 +90,19 @@ export function SelectField(props: SelectFieldProps) { } }); + createEffect(() => { + if (props.disabled) setOpen(false); + }); + return ( !props.disabled && setOpen(open)} placement={props.placement ?? "right-start"} > - + {selected() ?? props.defaultText}
diff --git a/frontend/src/features/colab/services/project.ts b/frontend/src/features/colab/services/project.ts index c8df583..e1c6f2c 100644 --- a/frontend/src/features/colab/services/project.ts +++ b/frontend/src/features/colab/services/project.ts @@ -7,10 +7,22 @@ import { BACKEND_HOST } from "@services"; import { showToast } from "@services/toast"; import { ProjectInfo } from "../types"; -import { wsSessionId } from "@features/ws/stores"; +import { projectInfo, setProjectAccess, setProjectInfo } from "../stores"; onWsMessage(ServerMessageKind.UpdateAccess, (msg) => { - if (msg.user_id === untrack(wsSessionId)) { + setProjectInfo((projectInfo) => ({ + ...projectInfo, + users: { + ...projectInfo.users, + [msg.user_id]: [ + projectInfo.users[msg.user_id]?.[0] ?? "Unknown", + msg.access, + ], + }, + })); + + if (msg.user_id === untrack(authInfo)?.id) { + setProjectAccess(msg.access); if (msg.access === AccessLevel.Editor) { showToast("success", { titleText: "You have been granted to edit", @@ -23,6 +35,18 @@ onWsMessage(ServerMessageKind.UpdateAccess, (msg) => { } }); +onWsMessage(ServerMessageKind.ProjectConfig, (msg) => { + const project_info = untrack(projectInfo); + if (project_info) { + setProjectInfo({ + ...project_info, + name: msg.name, + is_public: msg.is_public, + password: msg.password, + }); + } +}); + export async function createProject( owner: string, name: string = "Unnamed", @@ -52,7 +76,6 @@ export async function fetchProject( }, }); - const body = await res.text(); if (res.status === 401) { diff --git a/frontend/src/features/colab/stores/project.ts b/frontend/src/features/colab/stores/project.ts index db217ff..0ca8b0e 100644 --- a/frontend/src/features/colab/stores/project.ts +++ b/frontend/src/features/colab/stores/project.ts @@ -6,6 +6,8 @@ import { ProjectInfo } from "../types"; export const [projectAccess, setProjectAccess] = createSignal(AccessLevel.Queue); +export const [isProjectOwner, setIsProjectOwner] = createSignal(false); + export const [projectId, setProjectId] = createSignal(null); export const [projectInfo, setProjectInfo] = createSignal(null); diff --git a/frontend/src/features/colab/types/ProjectInfo.ts b/frontend/src/features/colab/types/ProjectInfo.ts index b35066b..11ceb4f 100644 --- a/frontend/src/features/colab/types/ProjectInfo.ts +++ b/frontend/src/features/colab/types/ProjectInfo.ts @@ -4,7 +4,7 @@ export interface ProjectInfo { id: string, name: string, owner: string, - allowed_users: Record, + users: Record, is_public: boolean, password?: string, } diff --git a/frontend/src/features/colab/utils/interceptProjectRoutes.ts b/frontend/src/features/colab/utils/interceptProjectRoutes.ts index 3c71a38..076d8c7 100644 --- a/frontend/src/features/colab/utils/interceptProjectRoutes.ts +++ b/frontend/src/features/colab/utils/interceptProjectRoutes.ts @@ -1,4 +1,4 @@ -import { observable, untrack } from "solid-js"; +import { batch, observable, untrack } from "solid-js"; import { authInfo } from "@features/auth/stores"; import { AuthInfo } from "@features/auth/types"; @@ -6,7 +6,12 @@ import { AccessLevel } from "@features/ws/types"; import { showToast } from "@services/toast"; import { createProject, fetchProject } from "../services"; -import { setProjectAccess, setProjectId, setProjectInfo } from "../stores"; +import { + setIsProjectOwner, + setProjectAccess, + setProjectId, + setProjectInfo, +} from "../stores"; export function interpectProjectRoutes() { if (window.location.pathname === "/") { @@ -31,7 +36,7 @@ export function interpectProjectRoutes() { fetchProject(projectId).then((project) => { // Check if has access to project - if (project.allowed_users == null) { + if (project.users == null) { // TODO: Pending permission, listen to permission granted. // Once user is allowed, should restart websocket connection // for receive welcome @@ -42,11 +47,17 @@ export function interpectProjectRoutes() { return; } - setProjectAccess( - project.allowed_users[untrack(authInfo).id] ?? AccessLevel.Queue, - ); - setProjectId(projectId); - setProjectInfo(project); + batch(() => { + if (project.owner === untrack(authInfo).id) { + setIsProjectOwner(true); + } + + setProjectAccess( + project.users[untrack(authInfo).id]?.[1] ?? AccessLevel.Queue, + ); + setProjectId(projectId); + setProjectInfo(project); + }); }).catch((err: [number, string]) => { if (err instanceof Array) { if (err[0] === 404) { diff --git a/frontend/src/features/colab/views/Colab.module.sass b/frontend/src/features/colab/views/Colab.module.sass index 71994da..de5c01a 100644 --- a/frontend/src/features/colab/views/Colab.module.sass +++ b/frontend/src/features/colab/views/Colab.module.sass @@ -18,7 +18,8 @@ width: max-content max-height: 95vh max-width: 95vw - overflow: auto + overflow-x: hidden + overflow-y: auto padding: 1rem 1rem background: var(--surface-background) @@ -35,10 +36,17 @@ gap: 2rem > div + width: 100% + max-width: 250px + display: flex flex-direction: column gap: .5rem +.password_hint + font-size: 11px + color: #999 + .buttons_container display: flex justify-content: space-around diff --git a/frontend/src/features/colab/views/Colab.tsx b/frontend/src/features/colab/views/Colab.tsx index 1df9656..c1d2c0c 100644 --- a/frontend/src/features/colab/views/Colab.tsx +++ b/frontend/src/features/colab/views/Colab.tsx @@ -1,13 +1,23 @@ import Dialog from "@corvu/dialog"; -import { For } from "solid-js"; +import { createEffect, createSignal, For, Show, untrack } from "solid-js"; import { SelectField } from "@components/SelectField"; import { Switchbox } from "@components/Switchbox"; import { TextField } from "@components/TextField"; +import { AccessLevel, ClientMessageKind } from "@features/ws/types"; +import { sendMessage } from "@features/ws/services"; import { LockIcon } from "@icons/Lock"; import { BrandsGithubIcon } from "@icons/BrandsGithub"; +import { showToast } from "@services/toast"; -import { isColabOpen, setIsColabOpen } from "../stores"; +import { + isColabOpen, + isProjectOwner, + projectId, + projectInfo, + setIsColabOpen, + setProjectInfo, +} from "../stores"; import styles from "./Colab.module.sass"; @@ -25,21 +35,8 @@ export function Colab() {

Room settings

- - - } - placeholder="Leave empty for no password" - /> - -
- - - -
+ +
@@ -51,16 +48,23 @@ export function Colab() { />
    - - {(name, idx) => ( + + {([user_id, [username, access]]) => (
  • - {name} + {username} { + sendMessage(ClientMessageKind.PermitAccess, { + user_id, + access, + }); + }} />
  • )} @@ -91,3 +95,83 @@ export function Colab() { ); } + +function ColabPublicPassword() { + const [password, setPassword] = createSignal(""); + + let debounce: NodeJS.Timeout; + let first_time = true; + createEffect(() => { + let pass = password(); + + if (first_time && (first_time = false, true)) return; + + if (debounce) clearTimeout(debounce); + + debounce = setTimeout(() => { + sendMessage(ClientMessageKind.Config, { password: pass }); + }, 500); + }); + + return ( + <> + + + + setPassword(ev.currentTarget.value)} + beforeIcon={} + placeholder="Leave empty for no password" + /> + Password is visible + + + ); +} + +function ColabButtons() { + const copyPath = (suffix = "") => { + navigator.clipboard.writeText( + `${location.protocol}//${location.host}/${projectId()}${suffix}`, + ); + }; + + return ( +
    + + + +
    + ); +} diff --git a/frontend/src/features/ws/types/client.d.ts b/frontend/src/features/ws/types/client.d.ts index 026db1b..95efc62 100644 --- a/frontend/src/features/ws/types/client.d.ts +++ b/frontend/src/features/ws/types/client.d.ts @@ -2,6 +2,7 @@ import { OtOperation, RsCursor } from "@features/editor/types"; import { AccessLevel } from "./access"; export enum ClientMessageKind { + Config = "config", PermitAccess = "permit_access", FileCreate = "file_create", FileDelete = "file_delete", @@ -11,6 +12,12 @@ export enum ClientMessageKind { } export type ClientMessage = { + [ClientMessageKind.Config]: { + action: ClientMessageKind.Config; + name?: string; + is_public?: boolean; + password?: string; + }; [ClientMessageKind.PermitAccess]: { action: ClientMessageKind.PermitAccess; user_id: string; diff --git a/frontend/src/features/ws/types/server.d.ts b/frontend/src/features/ws/types/server.d.ts index 3558811..dfdc992 100644 --- a/frontend/src/features/ws/types/server.d.ts +++ b/frontend/src/features/ws/types/server.d.ts @@ -3,6 +3,7 @@ import { AccessLevel } from "./access"; export enum ServerMessageKind { Error = "error", + ProjectConfig = "project_config", ProjectFiles = "project_files", UpdateAccess = "update_access", UserConnected = "user_connected", @@ -16,6 +17,12 @@ export type ServerMessage = { action: ServerMessageKind.Error; message: string; }; + [ServerMessageKind.ProjectConfig]: { + action: ServerMessageKind.ProjectConfig; + name: string; + is_public: boolean; + password?: string; + }; [ServerMessageKind.ProjectFiles]: { action: ServerMessageKind.ProjectFiles; files: Record; From 10992028f67b510ae77e64d0bbcc372449fe2259 Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Fri, 2 May 2025 22:49:55 -0700 Subject: [PATCH 05/14] fix(backend/project): Change password format in query --- backend/src/project/routes.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/project/routes.rs b/backend/src/project/routes.rs index 7b73b11..a2c821c 100644 --- a/backend/src/project/routes.rs +++ b/backend/src/project/routes.rs @@ -18,7 +18,11 @@ pub async fn get_project( ) -> HttpResult { let app_state = app_state.into_inner(); let project_id = project_id.into_inner(); - let password = Some(req.query_string().to_owned()).take_if(|s| !s.is_empty()); + let password = req + .query_string() + .strip_prefix("p=") + .take_if(|s| !s.is_empty()) + .map(|s| s.to_owned()); let user_info = jwt::get_user_info(&req)?; From 2512bd0b067fa8b35b07bb769118af80083f4c31 Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Sat, 3 May 2025 11:54:45 -0700 Subject: [PATCH 06/14] feat(frontend): Add modal service --- frontend/src/services/modal.module.sass | 49 +++++++++++++++++++++++++ frontend/src/services/modal.ts | 45 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 frontend/src/services/modal.module.sass create mode 100644 frontend/src/services/modal.ts diff --git a/frontend/src/services/modal.module.sass b/frontend/src/services/modal.module.sass new file mode 100644 index 0000000..30059f0 --- /dev/null +++ b/frontend/src/services/modal.module.sass @@ -0,0 +1,49 @@ +@use "../styles/_mixins.sass" as * + +@mixin timer($light, $dark) + +themed_var(timer-color, $light, $dark) + +.animation_show + +animate(toast-show, forwards 300ms) + 0% + scale: 0 + opacity: 0 + + 60% + scale: 1.05 + opacity: 1 + + 100% + scale: 1 + +.animation_hide + +animate(toast-hide, forwards 300ms) + 0% + scale: 1 + + 40% + scale: 1.05 + opacity: 1 + + 100% + scale: 0.05 + opacity: 0 + +.container + --timer-color: inherit + --border-color: #000 + --icon-color: var(--border-color) + --swal2-html-container-padding: 1rem + + .popup + +neobrutalism + + background: var(--surface-background) + color: var(--surface-foreground) + + padding: .5rem + border-radius: var(--radius-md) + border-color: var(--border-color) + + & :global(.swal2-timer-progress-bar) + background: var(--timer-color) diff --git a/frontend/src/services/modal.ts b/frontend/src/services/modal.ts new file mode 100644 index 0000000..ee51538 --- /dev/null +++ b/frontend/src/services/modal.ts @@ -0,0 +1,45 @@ +import { JSX, untrack } from "solid-js"; +import SWAL, { SweetAlertOptions } from "sweetalert2"; + +import { ThemeMode, themeMode } from "@features/theme"; + +import styles from "./modal.module.sass"; +import { render } from "solid-js/web"; + +const baseToast = SWAL.mixin({ + position: "center", + allowOutsideClick: false, + showConfirmButton: false, + showClass: { + popup: styles.animation_show, + }, + hideClass: { + popup: styles.animation_hide, + }, + customClass: { + container: styles.container, + popup: styles.popup, + }, +}); + +export function showModal( + content: () => JSX.Element, + options: SweetAlertOptions = {} +): ReturnType> { + const elem = document.createElement("div"); + elem.style.display = "contents"; + + return baseToast.fire({ + theme: untrack(themeMode) === ThemeMode.System + ? "auto" + : untrack(themeMode) === ThemeMode.Dark + ? "dark" + : "light", + ...options, + html: elem, + didOpen() { + render(content, elem); + } + }); +} + From 2225476d565ce9cf6a8286e6148fd0f5e92ed01f Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Sat, 3 May 2025 11:56:13 -0700 Subject: [PATCH 07/14] feat(frontend/colab): Add password request --- .../src/features/colab/services/project.ts | 47 +++++++++++++++-- .../colab/utils/interceptProjectRoutes.ts | 45 +++------------- .../colab/views/RequestPassword.module.sass | 23 ++++++++ .../features/colab/views/RequestPassword.tsx | 52 +++++++++++++++++++ frontend/src/features/colab/views/index.ts | 1 + frontend/src/icons/ErrorIcon.tsx | 18 +++++++ 6 files changed, 145 insertions(+), 41 deletions(-) create mode 100644 frontend/src/features/colab/views/RequestPassword.module.sass create mode 100644 frontend/src/features/colab/views/RequestPassword.tsx create mode 100644 frontend/src/icons/ErrorIcon.tsx diff --git a/frontend/src/features/colab/services/project.ts b/frontend/src/features/colab/services/project.ts index e1c6f2c..e862791 100644 --- a/frontend/src/features/colab/services/project.ts +++ b/frontend/src/features/colab/services/project.ts @@ -1,13 +1,20 @@ -import { untrack } from "solid-js/web"; +import { untrack, batch } from "solid-js"; +import SWAL from "sweetalert2"; import { authInfo } from "@features/auth/stores"; -import { onWsMessage } from "@features/ws/services"; +import { onWsMessage, startWebsocket } from "@features/ws/services"; import { AccessLevel, ServerMessageKind } from "@features/ws/types"; import { BACKEND_HOST } from "@services"; import { showToast } from "@services/toast"; import { ProjectInfo } from "../types"; -import { projectInfo, setProjectAccess, setProjectInfo } from "../stores"; +import { + projectInfo, + setIsProjectOwner, + setProjectAccess, + setProjectId, + setProjectInfo, +} from "../stores"; onWsMessage(ServerMessageKind.UpdateAccess, (msg) => { setProjectInfo((projectInfo) => ({ @@ -47,6 +54,38 @@ onWsMessage(ServerMessageKind.ProjectConfig, (msg) => { } }); +export function setProject(project: ProjectInfo) { + // Check if has access to project + if (project.users == null) { + // TODO: Pending permission, listen to permission granted. + // Once user is allowed, should restart websocket connection + // for receive welcome + setProjectId(project.id); + showToast("error", { + titleText: "Not access to project", + }); + return; + } + + batch(() => { + if (project.owner === untrack(authInfo).id) { + setIsProjectOwner(true); + } + + setProjectAccess( + project.users[untrack(authInfo).id]?.[1] ?? AccessLevel.Queue, + ); + setProjectId(project.id); + setProjectInfo(project); + }); + + // Close current modal, maybe it is password + // or waiting screen + SWAL.close() + + startWebsocket() +} + export async function createProject( owner: string, name: string = "Unnamed", @@ -69,7 +108,7 @@ export async function fetchProject( project_id: string, password = "", ): Promise { - let res = await fetch(`${BACKEND_HOST}/project/${project_id}?${password}`, { + let res = await fetch(`${BACKEND_HOST}/project/${project_id}?p=${password}`, { method: "GET", headers: { Authorization: `Bearer ${untrack(authInfo)?.jwt}`, diff --git a/frontend/src/features/colab/utils/interceptProjectRoutes.ts b/frontend/src/features/colab/utils/interceptProjectRoutes.ts index 076d8c7..13552fc 100644 --- a/frontend/src/features/colab/utils/interceptProjectRoutes.ts +++ b/frontend/src/features/colab/utils/interceptProjectRoutes.ts @@ -1,17 +1,13 @@ -import { batch, observable, untrack } from "solid-js"; +import { observable, untrack } from "solid-js"; import { authInfo } from "@features/auth/stores"; import { AuthInfo } from "@features/auth/types"; -import { AccessLevel } from "@features/ws/types"; +import { showModal } from "@services/modal"; import { showToast } from "@services/toast"; -import { createProject, fetchProject } from "../services"; -import { - setIsProjectOwner, - setProjectAccess, - setProjectId, - setProjectInfo, -} from "../stores"; +import { createProject, fetchProject, setProject } from "../services"; +import { setProjectId } from "../stores"; +import { RequestPassword } from "../views"; export function interpectProjectRoutes() { if (window.location.pathname === "/") { @@ -34,31 +30,7 @@ export function interpectProjectRoutes() { return; } - fetchProject(projectId).then((project) => { - // Check if has access to project - if (project.users == null) { - // TODO: Pending permission, listen to permission granted. - // Once user is allowed, should restart websocket connection - // for receive welcome - setProjectId(projectId); - showToast("error", { - titleText: "Not access to project", - }); - return; - } - - batch(() => { - if (project.owner === untrack(authInfo).id) { - setIsProjectOwner(true); - } - - setProjectAccess( - project.users[untrack(authInfo).id]?.[1] ?? AccessLevel.Queue, - ); - setProjectId(projectId); - setProjectInfo(project); - }); - }).catch((err: [number, string]) => { + fetchProject(projectId).then(setProject).catch((err: [number, string]) => { if (err instanceof Array) { if (err[0] === 404) { showToast("error", { @@ -77,9 +49,8 @@ export function interpectProjectRoutes() { } if (err[0] == 401) { - showToast("error", { - titleText: "Invalid password", - }); + setProjectId(projectId); + showModal(RequestPassword); return; } } diff --git a/frontend/src/features/colab/views/RequestPassword.module.sass b/frontend/src/features/colab/views/RequestPassword.module.sass new file mode 100644 index 0000000..5292d15 --- /dev/null +++ b/frontend/src/features/colab/views/RequestPassword.module.sass @@ -0,0 +1,23 @@ +@use "../../../styles/_mixins.sass" as * + +.container + display: flex + flex-direction: column + gap: 1rem + + > button + +neobrutalism + + padding: 0.5rem 1rem + + background: var(--colors-primary) + + font-weight: 600 + +.error + color: var(--colors-error) + + display: flex + justify-content: center + align-items: center + gap: 0.5rem diff --git a/frontend/src/features/colab/views/RequestPassword.tsx b/frontend/src/features/colab/views/RequestPassword.tsx new file mode 100644 index 0000000..e7179eb --- /dev/null +++ b/frontend/src/features/colab/views/RequestPassword.tsx @@ -0,0 +1,52 @@ +import { createSignal, Show } from "solid-js"; + +import { TextField } from "@components/TextField"; +import { ErrorIcon } from "@icons/ErrorIcon"; +import { LockIcon } from "@icons/Lock"; + +import { fetchProject, setProject } from "../services"; +import { projectId } from "../stores"; + +import styles from "./RequestPassword.module.sass"; + +export function RequestPassword() { + const [hint, setHint] = createSignal(""); + const [password, setPassword] = createSignal(""); + + return ( +
    +

    This project needs password

    + { + setHint(""); + setPassword(ev.currentTarget.value); + }} + beforeIcon={} + placeholder="Enter password" + /> + + + {hint()} + + + +
    + ); +} diff --git a/frontend/src/features/colab/views/index.ts b/frontend/src/features/colab/views/index.ts index cabd21e..d0139ab 100644 --- a/frontend/src/features/colab/views/index.ts +++ b/frontend/src/features/colab/views/index.ts @@ -1 +1,2 @@ export * from "./Colab" +export * from "./RequestPassword" diff --git a/frontend/src/icons/ErrorIcon.tsx b/frontend/src/icons/ErrorIcon.tsx new file mode 100644 index 0000000..c5753b7 --- /dev/null +++ b/frontend/src/icons/ErrorIcon.tsx @@ -0,0 +1,18 @@ +import { ComponentProps } from "solid-js"; + +export function ErrorIcon(props: ComponentProps<"svg">) { + return ( + + + + + ); +} From 86b26cf876a82c5699e220aeaa604718fc348f6c Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Sat, 3 May 2025 18:47:52 -0700 Subject: [PATCH 08/14] chore(frontend/colab): Change `projectInfo` to store --- .../src/features/colab/services/project.ts | 47 +++++------------- frontend/src/features/colab/stores/project.ts | 10 ++-- .../src/features/colab/types/ProjectInfo.ts | 5 +- .../colab/utils/interceptProjectRoutes.ts | 4 +- frontend/src/features/colab/views/Colab.tsx | 27 ++++------- .../features/colab/views/RequestPassword.tsx | 4 +- frontend/src/features/ws/services/ws.ts | 48 +++++++++++-------- 7 files changed, 65 insertions(+), 80 deletions(-) diff --git a/frontend/src/features/colab/services/project.ts b/frontend/src/features/colab/services/project.ts index e862791..09222df 100644 --- a/frontend/src/features/colab/services/project.ts +++ b/frontend/src/features/colab/services/project.ts @@ -1,32 +1,19 @@ -import { untrack, batch } from "solid-js"; +import { batch, untrack } from "solid-js"; import SWAL from "sweetalert2"; import { authInfo } from "@features/auth/stores"; import { onWsMessage, startWebsocket } from "@features/ws/services"; import { AccessLevel, ServerMessageKind } from "@features/ws/types"; import { BACKEND_HOST } from "@services"; +import { showModal } from "@services/modal"; import { showToast } from "@services/toast"; import { ProjectInfo } from "../types"; -import { - projectInfo, - setIsProjectOwner, - setProjectAccess, - setProjectId, - setProjectInfo, -} from "../stores"; +import { setIsProjectOwner, setProjectAccess, setProjectInfo } from "../stores"; +import { WaitingAccess } from "../views"; onWsMessage(ServerMessageKind.UpdateAccess, (msg) => { - setProjectInfo((projectInfo) => ({ - ...projectInfo, - users: { - ...projectInfo.users, - [msg.user_id]: [ - projectInfo.users[msg.user_id]?.[0] ?? "Unknown", - msg.access, - ], - }, - })); + setProjectInfo("users", msg.user_id, 1, msg.access); if (msg.user_id === untrack(authInfo)?.id) { setProjectAccess(msg.access); @@ -43,15 +30,11 @@ onWsMessage(ServerMessageKind.UpdateAccess, (msg) => { }); onWsMessage(ServerMessageKind.ProjectConfig, (msg) => { - const project_info = untrack(projectInfo); - if (project_info) { - setProjectInfo({ - ...project_info, - name: msg.name, - is_public: msg.is_public, - password: msg.password, - }); - } + setProjectInfo({ + name: msg.name, + is_public: msg.is_public, + password: msg.password, + }); }); export function setProject(project: ProjectInfo) { @@ -60,10 +43,7 @@ export function setProject(project: ProjectInfo) { // TODO: Pending permission, listen to permission granted. // Once user is allowed, should restart websocket connection // for receive welcome - setProjectId(project.id); - showToast("error", { - titleText: "Not access to project", - }); + setProjectInfo("id", project.id); return; } @@ -75,15 +55,14 @@ export function setProject(project: ProjectInfo) { setProjectAccess( project.users[untrack(authInfo).id]?.[1] ?? AccessLevel.Queue, ); - setProjectId(project.id); setProjectInfo(project); }); // Close current modal, maybe it is password // or waiting screen - SWAL.close() + SWAL.close(); - startWebsocket() + startWebsocket(); } export async function createProject( diff --git a/frontend/src/features/colab/stores/project.ts b/frontend/src/features/colab/stores/project.ts index 0ca8b0e..1d108f7 100644 --- a/frontend/src/features/colab/stores/project.ts +++ b/frontend/src/features/colab/stores/project.ts @@ -1,4 +1,5 @@ import { createSignal } from "solid-js"; +import { createStore } from "solid-js/store"; import { AccessLevel } from "@features/ws/types"; @@ -8,6 +9,9 @@ export const [projectAccess, setProjectAccess] = createSignal(Acces export const [isProjectOwner, setIsProjectOwner] = createSignal(false); -export const [projectId, setProjectId] = createSignal(null); - -export const [projectInfo, setProjectInfo] = createSignal(null); +export const [projectInfo, setProjectInfo] = createStore({ + id: "", + users: {}, + requests: {}, + is_public: false, +}); diff --git a/frontend/src/features/colab/types/ProjectInfo.ts b/frontend/src/features/colab/types/ProjectInfo.ts index 11ceb4f..4097aaf 100644 --- a/frontend/src/features/colab/types/ProjectInfo.ts +++ b/frontend/src/features/colab/types/ProjectInfo.ts @@ -2,9 +2,10 @@ import { AccessLevel } from "@features/ws/types"; export interface ProjectInfo { id: string, - name: string, - owner: string, + name?: string, + owner?: string, users: Record, + requests: Record, is_public: boolean, password?: string, } diff --git a/frontend/src/features/colab/utils/interceptProjectRoutes.ts b/frontend/src/features/colab/utils/interceptProjectRoutes.ts index 13552fc..883f7f4 100644 --- a/frontend/src/features/colab/utils/interceptProjectRoutes.ts +++ b/frontend/src/features/colab/utils/interceptProjectRoutes.ts @@ -6,8 +6,8 @@ import { showModal } from "@services/modal"; import { showToast } from "@services/toast"; import { createProject, fetchProject, setProject } from "../services"; -import { setProjectId } from "../stores"; import { RequestPassword } from "../views"; +import { setProjectInfo } from "../stores"; export function interpectProjectRoutes() { if (window.location.pathname === "/") { @@ -49,7 +49,7 @@ export function interpectProjectRoutes() { } if (err[0] == 401) { - setProjectId(projectId); + setProjectInfo("id", projectId); showModal(RequestPassword); return; } diff --git a/frontend/src/features/colab/views/Colab.tsx b/frontend/src/features/colab/views/Colab.tsx index c1d2c0c..c532100 100644 --- a/frontend/src/features/colab/views/Colab.tsx +++ b/frontend/src/features/colab/views/Colab.tsx @@ -1,5 +1,5 @@ import Dialog from "@corvu/dialog"; -import { createEffect, createSignal, For, Show, untrack } from "solid-js"; +import { createEffect, createSignal, For, Show } from "solid-js"; import { SelectField } from "@components/SelectField"; import { Switchbox } from "@components/Switchbox"; @@ -13,7 +13,6 @@ import { showToast } from "@services/toast"; import { isColabOpen, isProjectOwner, - projectId, projectInfo, setIsColabOpen, setProjectInfo, @@ -48,7 +47,7 @@ export function Colab() { />
      - + {([user_id, [username, access]]) => (
    • @@ -118,25 +117,19 @@ function ColabPublicPassword() { - + setPassword(ev.currentTarget.value)} beforeIcon={} placeholder="Leave empty for no password" @@ -150,7 +143,7 @@ function ColabPublicPassword() { function ColabButtons() { const copyPath = (suffix = "") => { navigator.clipboard.writeText( - `${location.protocol}//${location.host}/${projectId()}${suffix}`, + `${location.protocol}//${location.host}/${projectInfo.id}${suffix}`, ); }; diff --git a/frontend/src/features/colab/views/RequestPassword.tsx b/frontend/src/features/colab/views/RequestPassword.tsx index e7179eb..dda2f57 100644 --- a/frontend/src/features/colab/views/RequestPassword.tsx +++ b/frontend/src/features/colab/views/RequestPassword.tsx @@ -5,7 +5,7 @@ import { ErrorIcon } from "@icons/ErrorIcon"; import { LockIcon } from "@icons/Lock"; import { fetchProject, setProject } from "../services"; -import { projectId } from "../stores"; +import { projectInfo } from "../stores"; import styles from "./RequestPassword.module.sass"; @@ -34,7 +34,7 @@ export function RequestPassword() { if (!password()) { setHint("Put some text :)"); } else { - fetchProject(projectId(), password()).then(setProject).catch( + fetchProject(projectInfo.id, password()).then(setProject).catch( (err) => { if ( err instanceof Array && err[0] == 401 && diff --git a/frontend/src/features/ws/services/ws.ts b/frontend/src/features/ws/services/ws.ts index 25bd367..a89b446 100644 --- a/frontend/src/features/ws/services/ws.ts +++ b/frontend/src/features/ws/services/ws.ts @@ -1,7 +1,7 @@ import { getOwner, observable, Owner, runWithOwner, untrack } from "solid-js"; import { authInfo } from "@features/auth/stores"; -import { projectId } from "@features/colab/stores"; +import { projectInfo } from "@features/colab/stores"; import { openFile } from "@features/editor/services"; import { syncFiles } from "@features/file-explorer/services"; import { BACKEND_HOST } from "@services"; @@ -23,36 +23,44 @@ import { let wsOwner: Owner; +let subs: (() => void)[] = []; export function startWebsocket() { - observable(() => [authInfo(), projectId()] as const).subscribe( - ([authInfo, projectId]) => { - if (!!authInfo?.jwt && !!projectId) { - wsSession()?.close(); - connectWs(authInfo.jwt, projectId); - } else { - setWsSession(null); - } - }, + subs.forEach((sub) => sub()); + subs = []; + + subs.push( + observable(() => [authInfo(), projectInfo.id] as const).subscribe( + ([authInfo, projectId]) => { + if (!!authInfo?.jwt && !!projectId) { + wsSession()?.close(); + connectWs(authInfo.jwt, projectId); + } else { + setWsSession(null); + } + }, + ).unsubscribe, ); - observable(wsSession).subscribe((wsSession) => { - if (wsSession) { - for (const msg of wsQueue) { - wsSession.send(JSON.stringify(msg)); + subs.push( + observable(wsSession).subscribe((wsSession) => { + if (wsSession) { + for (const msg of wsQueue) { + wsSession.send(JSON.stringify(msg)); + } + clearWsQueue(); } - clearWsQueue(); - } - }); + }).unsubscribe, + ); - wsOwner = getOwner(); + wsOwner = getOwner() ?? wsOwner; - onWsMessage(ServerMessageKind.Welcome, (msg) => { + subs.push(onWsMessage(ServerMessageKind.Welcome, (msg) => { setWsSessionId(msg.session_id); syncFiles(msg.files); openFile("main.rs"); - }); + })); } const wsUrl = new URL(BACKEND_HOST); From 2c7b0dba06ddaa80e2b3349465b7d2c8c7141955 Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Fri, 16 May 2025 13:29:39 -0700 Subject: [PATCH 09/14] feat(backend): Add request access --- backend/src/project/project.rs | 14 +++++++++++++- backend/src/project/routes.rs | 2 ++ backend/src/ws/handlers.rs | 34 ++++++++++++++++++++++++++++++++++ backend/src/ws/messages.rs | 6 ++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/backend/src/project/project.rs b/backend/src/project/project.rs index b54717e..a435fef 100644 --- a/backend/src/project/project.rs +++ b/backend/src/project/project.rs @@ -1,8 +1,9 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tokio::sync::broadcast; use uuid::Uuid; +use crate::auth::jwt::RgUserData; use crate::collab::{Document, DocumentInfo}; use crate::http_errors::HttpErrors; use crate::ws::messages::ServerMessage; @@ -15,6 +16,7 @@ pub struct Project { pub owner: String, pub documents: HashMap, pub allowed_users: HashMap, + pub requests: HashSet, pub is_public: bool, pub password: Option, pub broadcast: broadcast::Sender, @@ -28,6 +30,7 @@ impl Default for Project { owner: String::new(), documents: HashMap::new(), allowed_users: HashMap::new(), + requests: HashSet::new(), is_public: true, password: None, broadcast: broadcast::channel(u8::MAX as usize).0, @@ -44,6 +47,15 @@ impl Project { } } + pub fn add_request(&mut self, user_info: &RgUserData) { + if self.requests.insert(user_info.id.clone()) { + _ = self.broadcast.send(ServerMessage::RequestAccess { + user_id: user_info.id.clone(), + user_name: user_info.name.clone(), + }); + } + } + pub fn permit_access(&mut self, user_id: String, access: AccessLevel) { self.allowed_users.insert(user_id, access); } diff --git a/backend/src/project/routes.rs b/backend/src/project/routes.rs index a2c821c..1a8d97b 100644 --- a/backend/src/project/routes.rs +++ b/backend/src/project/routes.rs @@ -34,6 +34,8 @@ pub async fn get_project( let access = project.join_project(&user_info.id, password)?; if !access.can_read() { + project.add_request(&user_info); + return Ok(HttpResponse::Unauthorized().json(json!({ "access": access, "id": project.id, diff --git a/backend/src/ws/handlers.rs b/backend/src/ws/handlers.rs index 0ebfb7c..0f771a1 100644 --- a/backend/src/ws/handlers.rs +++ b/backend/src/ws/handlers.rs @@ -22,6 +22,11 @@ impl RgWebsocket { } pub async fn handle_welcome(&self, ctx: &mut ws::Session) { + // Don't send welcome when is in queue + if !self.access.can_read() { + return; + } + Self::handle_ws_response(ctx, self.compose_welcome().await).await } @@ -45,10 +50,27 @@ impl RgWebsocket { }) .collect(); + let requests = if project.owner == self.user_info.id { + Some( + project + .requests + .iter() + .filter_map(|user| { + self.app_state + .get_username(&user) + .map(|username| (user.clone(), username)) + }) + .collect(), + ) + } else { + None + }; + Ok(ServerMessage::Welcome { session_id: self.session_id.clone(), files, users, + requests, }) } @@ -61,6 +83,18 @@ impl RgWebsocket { .text_json(&ServerMessage::UpdateAccess { access, user_id }) .await; } + // Only update requests to owner + ServerMessage::RequestAccess { .. } + if self + .app_state + .get_manager() + .get_project(&self.project_id) + .is_some_and(|p| p.owner == self.user_info.id) => + { + _ = ctx.text_json(&msg).await + } + ServerMessage::RequestAccess { .. } => {} + _ if self.access.can_read() => _ = ctx.text_json(&msg).await, _ => {} } diff --git a/backend/src/ws/messages.rs b/backend/src/ws/messages.rs index 8f5e5ee..3473b42 100644 --- a/backend/src/ws/messages.rs +++ b/backend/src/ws/messages.rs @@ -59,6 +59,10 @@ pub enum ServerMessage { user_id: String, user_name: String, }, + RequestAccess { + user_id: String, + user_name: String, + }, Sync { file: String, revision: usize, @@ -72,6 +76,8 @@ pub enum ServerMessage { session_id: String, files: HashMap, users: HashMap, + // Only for owner + requests: Option>, }, } From e241055d61567753a6baf95c15d1e9a55e88fbfa Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Fri, 16 May 2025 13:55:43 -0700 Subject: [PATCH 10/14] feat(frontend/colab): add request access --- .../src/features/colab/services/project.ts | 25 +++++++++- frontend/src/features/colab/views/Colab.tsx | 32 +++++++++--- .../colab/views/WaitingAccess.module.sass | 33 +++++++++++++ .../features/colab/views/WaitingAccess.tsx | 49 +++++++++++++++++++ frontend/src/features/colab/views/index.ts | 1 + frontend/src/features/ws/types/server.d.ts | 6 +++ 6 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 frontend/src/features/colab/views/WaitingAccess.module.sass create mode 100644 frontend/src/features/colab/views/WaitingAccess.tsx diff --git a/frontend/src/features/colab/services/project.ts b/frontend/src/features/colab/services/project.ts index 09222df..1c76a01 100644 --- a/frontend/src/features/colab/services/project.ts +++ b/frontend/src/features/colab/services/project.ts @@ -9,7 +9,12 @@ import { showModal } from "@services/modal"; import { showToast } from "@services/toast"; import { ProjectInfo } from "../types"; -import { setIsProjectOwner, setProjectAccess, setProjectInfo } from "../stores"; +import { + projectInfo, + setIsProjectOwner, + setProjectAccess, + setProjectInfo, +} from "../stores"; import { WaitingAccess } from "../views"; onWsMessage(ServerMessageKind.UpdateAccess, (msg) => { @@ -17,14 +22,25 @@ onWsMessage(ServerMessageKind.UpdateAccess, (msg) => { if (msg.user_id === untrack(authInfo)?.id) { setProjectAccess(msg.access); + if (msg.access === AccessLevel.Editor) { + projectInfo.requests[msg.user_id] && + setProjectInfo("requests", msg.user_id, undefined); showToast("success", { titleText: "You have been granted to edit", }); } else if (msg.access === AccessLevel.ReadOnly) { + projectInfo.requests[msg.user_id] && + setProjectInfo("requests", msg.user_id, undefined); showToast("success", { titleText: "You have been granted to read", }); + } else if (msg.access === AccessLevel.Queue) { + projectInfo.requests[msg.user_id] && + setProjectInfo("requests", msg.user_id, undefined); + showToast("error", { + titleText: "You have been kicked", + }); } } }); @@ -37,6 +53,10 @@ onWsMessage(ServerMessageKind.ProjectConfig, (msg) => { }); }); +onWsMessage(ServerMessageKind.RequestAccess, (msg) => { + setProjectInfo("requests", msg.user_id, msg.user_name); +}); + export function setProject(project: ProjectInfo) { // Check if has access to project if (project.users == null) { @@ -44,6 +64,9 @@ export function setProject(project: ProjectInfo) { // Once user is allowed, should restart websocket connection // for receive welcome setProjectInfo("id", project.id); + showModal(WaitingAccess, { + allowOutsideClick: false, + }); return; } diff --git a/frontend/src/features/colab/views/Colab.tsx b/frontend/src/features/colab/views/Colab.tsx index c532100..bfca2ca 100644 --- a/frontend/src/features/colab/views/Colab.tsx +++ b/frontend/src/features/colab/views/Colab.tsx @@ -21,8 +21,6 @@ import { import styles from "./Colab.module.sass"; export function Colab() { - const requestUsers = ["CHIWO", "Jopzgo", "gg0074x", "Otro"]; - return ( @@ -72,16 +70,37 @@ export function Colab() {

      Pending Requests

        - - {(name) => ( + + {([user, name]) => (
      • {name}
          - - + +
      • )} @@ -145,6 +164,7 @@ function ColabButtons() { navigator.clipboard.writeText( `${location.protocol}//${location.host}/${projectInfo.id}${suffix}`, ); + showToast("success", { text: "Link copied" }); }; return ( diff --git a/frontend/src/features/colab/views/WaitingAccess.module.sass b/frontend/src/features/colab/views/WaitingAccess.module.sass new file mode 100644 index 0000000..71dbfc9 --- /dev/null +++ b/frontend/src/features/colab/views/WaitingAccess.module.sass @@ -0,0 +1,33 @@ +@use "../../../styles/_mixins.sass" as * + +.container + display: flex + flex-direction: column + gap: 1rem + +.username_paragraph + font-size: 1rem + +.username + color: var(--colors-primary) + +.actions + display: flex + gap: 0.5rem + justify-content: center + + :where(&) > button + +neobrutalism + + padding: 0.25rem .75rem + + background: var(--colors-primary) + font-weight: 700 + + display: flex + gap: 0.25rem + align-items: center + +.action_secondary + background: var(--colors-secondary) + diff --git a/frontend/src/features/colab/views/WaitingAccess.tsx b/frontend/src/features/colab/views/WaitingAccess.tsx new file mode 100644 index 0000000..00c7719 --- /dev/null +++ b/frontend/src/features/colab/views/WaitingAccess.tsx @@ -0,0 +1,49 @@ +import { createEffect } from "solid-js"; +import SWAL from "sweetalert2"; + +import { loginGithub } from "@features/auth/services"; +import { authInfo } from "@features/auth/stores"; +import { AccessLevel } from "@features/ws/types"; +import { BrandsGithubIcon } from "@icons/BrandsGithub"; + +import { projectAccess } from "../stores"; +import { createProject } from "../services"; + +import styles from "./WaitingAccess.module.sass"; + +export function WaitingAccess() { + createEffect(() => { + if (projectAccess() !== AccessLevel.Queue) { + SWAL.close(); + } + }); + return ( +
        +

        Project is private

        + Wait for your access request to be accepted. +

        + Your name is: {authInfo().name} +

        +
        + + +
        +
        + ); +} diff --git a/frontend/src/features/colab/views/index.ts b/frontend/src/features/colab/views/index.ts index d0139ab..035fa1f 100644 --- a/frontend/src/features/colab/views/index.ts +++ b/frontend/src/features/colab/views/index.ts @@ -1,2 +1,3 @@ export * from "./Colab" export * from "./RequestPassword" +export * from "./WaitingAccess" diff --git a/frontend/src/features/ws/types/server.d.ts b/frontend/src/features/ws/types/server.d.ts index dfdc992..c446250 100644 --- a/frontend/src/features/ws/types/server.d.ts +++ b/frontend/src/features/ws/types/server.d.ts @@ -7,6 +7,7 @@ export enum ServerMessageKind { ProjectFiles = "project_files", UpdateAccess = "update_access", UserConnected = "user_connected", + RequestAccess = "request_access", Sync = "sync", SyncCursors = "sync_cursors", Welcome = "welcome", @@ -36,6 +37,11 @@ export type ServerMessage = { action: ServerMessageKind.UserConnected; user_id: string; }; + [ServerMessageKind.RequestAccess]: { + action: ServerMessageKind.RequestAccess; + user_id: string; + user_name: string; + }; [ServerMessageKind.Sync]: { action: ServerMessageKind.Sync; file: string; From c61aad6327b66660d43635f0966eef485a292182 Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Fri, 16 May 2025 21:02:40 -0700 Subject: [PATCH 11/14] feat(frontend/colab): Implement fork project --- .../src/features/colab/services/project.ts | 24 ++++++++++++++++++- .../colab/utils/interceptProjectRoutes.ts | 13 +++------- frontend/src/features/colab/views/Colab.tsx | 10 ++------ 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/frontend/src/features/colab/services/project.ts b/frontend/src/features/colab/services/project.ts index 1c76a01..6b24e0a 100644 --- a/frontend/src/features/colab/services/project.ts +++ b/frontend/src/features/colab/services/project.ts @@ -57,7 +57,7 @@ onWsMessage(ServerMessageKind.RequestAccess, (msg) => { setProjectInfo("requests", msg.user_id, msg.user_name); }); -export function setProject(project: ProjectInfo) { +export function setProject(project: ProjectInfo, shouldFork: boolean) { // Check if has access to project if (project.users == null) { // TODO: Pending permission, listen to permission granted. @@ -70,6 +70,11 @@ export function setProject(project: ProjectInfo) { return; } + if (shouldFork) { + forkProject(project.id); + return; + } + batch(() => { if (project.owner === untrack(authInfo).id) { setIsProjectOwner(true); @@ -131,3 +136,20 @@ export async function fetchProject( return JSON.parse(body); } + +export async function forkProject(project_id: string) { + let res = await fetch(`${BACKEND_HOST}/fork/${project_id}`, { + method: "POST", + headers: { + Authorization: `Bearer ${untrack(authInfo)?.jwt}`, + }, + }); + + if (!res.ok) { + return; + } + + const { id } = await res.json(); + + location.pathname = "/" + id; +} diff --git a/frontend/src/features/colab/utils/interceptProjectRoutes.ts b/frontend/src/features/colab/utils/interceptProjectRoutes.ts index 883f7f4..a254e07 100644 --- a/frontend/src/features/colab/utils/interceptProjectRoutes.ts +++ b/frontend/src/features/colab/utils/interceptProjectRoutes.ts @@ -21,16 +21,9 @@ export function interpectProjectRoutes() { let projectId = segments.shift(); let maybeAction = segments.shift(); - if (maybeAction === "fork") { - // TODO: fork project - showToast("debug", { - titleText: "Fork project", - text: "Not implemented yet", - }); - return; - } - - fetchProject(projectId).then(setProject).catch((err: [number, string]) => { + fetchProject(projectId).then((project) => { + setProject(project, maybeAction === "fork"); + }).catch((err: [number, string]) => { if (err instanceof Array) { if (err[0] === 404) { showToast("error", { diff --git a/frontend/src/features/colab/views/Colab.tsx b/frontend/src/features/colab/views/Colab.tsx index bfca2ca..32c86ec 100644 --- a/frontend/src/features/colab/views/Colab.tsx +++ b/frontend/src/features/colab/views/Colab.tsx @@ -17,6 +17,7 @@ import { setIsColabOpen, setProjectInfo, } from "../stores"; +import { forkProject } from "../services"; import styles from "./Colab.module.sass"; @@ -175,14 +176,7 @@ function ColabButtons() { -
From 69afeb6cdb725cceeb690416b3f9a89db6938b7a Mon Sep 17 00:00:00 2001 From: Brayan-724 Date: Sun, 18 May 2025 11:07:04 -0700 Subject: [PATCH 12/14] fix(frontend/colab): Update access for non-owner --- .../src/features/colab/services/project.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/colab/services/project.ts b/frontend/src/features/colab/services/project.ts index 6b24e0a..ccdd685 100644 --- a/frontend/src/features/colab/services/project.ts +++ b/frontend/src/features/colab/services/project.ts @@ -10,6 +10,8 @@ import { showToast } from "@services/toast"; import { ProjectInfo } from "../types"; import { + isProjectOwner, + projectAccess, projectInfo, setIsProjectOwner, setProjectAccess, @@ -18,31 +20,36 @@ import { import { WaitingAccess } from "../views"; onWsMessage(ServerMessageKind.UpdateAccess, (msg) => { - setProjectInfo("users", msg.user_id, 1, msg.access); + if (!isProjectOwner()) { + const oldAccess = untrack(projectAccess); - if (msg.user_id === untrack(authInfo)?.id) { setProjectAccess(msg.access); + if (oldAccess === AccessLevel.Queue && msg.access !== AccessLevel.Queue) { + window.location.reload(); + return; + } + if (msg.access === AccessLevel.Editor) { - projectInfo.requests[msg.user_id] && - setProjectInfo("requests", msg.user_id, undefined); showToast("success", { titleText: "You have been granted to edit", }); } else if (msg.access === AccessLevel.ReadOnly) { - projectInfo.requests[msg.user_id] && - setProjectInfo("requests", msg.user_id, undefined); showToast("success", { titleText: "You have been granted to read", }); } else if (msg.access === AccessLevel.Queue) { - projectInfo.requests[msg.user_id] && - setProjectInfo("requests", msg.user_id, undefined); showToast("error", { titleText: "You have been kicked", }); } + return; } + + setProjectInfo("users", msg.user_id, 1, msg.access); + + projectInfo.requests[msg.user_id] && + setProjectInfo("requests", msg.user_id, undefined); }); onWsMessage(ServerMessageKind.ProjectConfig, (msg) => { From 122c71b46d636f942d81529ed6abe464657b162e Mon Sep 17 00:00:00 2001 From: Chiwa <74442646+gg0074x@users.noreply.github.com> Date: Sat, 14 Jun 2025 23:31:10 -0300 Subject: [PATCH 13/14] chore: changed colab layout --- frontend/.env.example | 1 - .../src/features/colab/services/project.ts | 7 +- .../features/colab/views/Colab.module.sass | 23 ++- frontend/src/features/colab/views/Colab.tsx | 177 ++++++++++-------- frontend/src/icons/Add.tsx | 18 ++ 5 files changed, 140 insertions(+), 86 deletions(-) delete mode 100644 frontend/.env.example create mode 100644 frontend/src/icons/Add.tsx diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 1e76ec8..0000000 --- a/frontend/.env.example +++ /dev/null @@ -1 +0,0 @@ -VITE_BACKEND_HOST=http://localhost:8080 diff --git a/frontend/src/features/colab/services/project.ts b/frontend/src/features/colab/services/project.ts index ccdd685..82e1c30 100644 --- a/frontend/src/features/colab/services/project.ts +++ b/frontend/src/features/colab/services/project.ts @@ -61,7 +61,12 @@ onWsMessage(ServerMessageKind.ProjectConfig, (msg) => { }); onWsMessage(ServerMessageKind.RequestAccess, (msg) => { - setProjectInfo("requests", msg.user_id, msg.user_name); + showToast("info", { + titleText: `${msg.user_name} is requesting access`, + timer: 5_000, + }).then(() => { + setProjectInfo("requests", msg.user_id, msg.user_name); + }); }); export function setProject(project: ProjectInfo, shouldFork: boolean) { diff --git a/frontend/src/features/colab/views/Colab.module.sass b/frontend/src/features/colab/views/Colab.module.sass index de5c01a..a1d0a68 100644 --- a/frontend/src/features/colab/views/Colab.module.sass +++ b/frontend/src/features/colab/views/Colab.module.sass @@ -36,7 +36,7 @@ gap: 2rem > div - width: 100% + width: 250px max-width: 250px display: flex @@ -49,7 +49,7 @@ .buttons_container display: flex - justify-content: space-around + justify-content: center flex-wrap: wrap gap: 0.25rem @@ -61,6 +61,25 @@ background: var(--colors-primary-400) color: black +.title_button_container + display: flex + justify-content: space-between + align-content: center + margin-top: 1rem + + > .subtitle + margin: 0 + + > button + +neobrutalism + + aspect-ratio: 1/1 + padding: 0.25rem + font-weight: 600 + background: var(--colors-primary-400) + color: black + + .title text-align: center margin-bottom: 0rem diff --git a/frontend/src/features/colab/views/Colab.tsx b/frontend/src/features/colab/views/Colab.tsx index 32c86ec..1c27161 100644 --- a/frontend/src/features/colab/views/Colab.tsx +++ b/frontend/src/features/colab/views/Colab.tsx @@ -20,93 +20,112 @@ import { import { forkProject } from "../services"; import styles from "./Colab.module.sass"; +import { AddIcon } from "@icons/Add"; +import { SolidUserIcon } from "@icons/SolidUser"; export function Colab() { + const [addMenu, setAddMenu] = createSignal(false); + return (

Live Collaboration

+
-
-

Room settings

- - - -
+ +
+

Room settings

+ +
+
-

Members

- - } - placeholder="Username" - /> - -
    - - {([user_id, [username, access]]) => ( -
  • - - {username} - - - { - sendMessage(ClientMessageKind.PermitAccess, { - user_id, - access, - }); - }} - /> -
  • - )} -
    -
- -

Pending Requests

-
    - - {([user, name]) => ( -
  • - - {name} - - -
      - + +
+ +
    + + {([user_id, [username, access]]) => ( +
  • + {username} + + { sendMessage(ClientMessageKind.PermitAccess, { - user_id: user, - access: AccessLevel.ReadOnly, + user_id, + access, }); }} - > - Allow - - -
- - )} - - + /> + + )} + + + + + +
+

Add

+ + + +
+ + } + placeholder="Username" + /> +
    + + {([user, name]) => ( +
  • + {name} + +
      + + +
    +
  • + )} +
    +
+
@@ -123,7 +142,7 @@ function ColabPublicPassword() { createEffect(() => { let pass = password(); - if (first_time && (first_time = false, true)) return; + if (first_time && ((first_time = false), true)) return; if (debounce) clearTimeout(debounce); @@ -170,15 +189,9 @@ function ColabButtons() { return (
- - - + + +
); } diff --git a/frontend/src/icons/Add.tsx b/frontend/src/icons/Add.tsx new file mode 100644 index 0000000..c7a9c3e --- /dev/null +++ b/frontend/src/icons/Add.tsx @@ -0,0 +1,18 @@ +import { ComponentProps } from "solid-js"; + +export function AddIcon(props: ComponentProps<"svg">) { + return ( + + + + ); +} From bb584a87bfccfe25a0319f9c2659e3873ef014d2 Mon Sep 17 00:00:00 2001 From: Chiwa <74442646+gg0074x@users.noreply.github.com> Date: Sun, 15 Jun 2025 00:04:04 -0300 Subject: [PATCH 14/14] hotfix: re added env.example sorry --- frontend/.env.example | 1 + 1 file changed, 1 insertion(+) create mode 100644 frontend/.env.example diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..1e76ec8 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_BACKEND_HOST=http://localhost:8080