From 9cd77f94d24261b14620865665f7cedbeb957327 Mon Sep 17 00:00:00 2001 From: Chris Carlon Date: Fri, 22 Nov 2024 23:27:49 +0000 Subject: [PATCH] feat!(frontend and backend): Memebers can now be added to workspaces, changed the backend memebers endpoint to accept an email not an id [2024-11-22] BREAKING CHANGE: Memebers can now be added to workspaces, changed the backend memebers endpoint to accept an email not an id --- gridwalk-backend/src/routes/workspace.rs | 74 +++-- gridwalk-ui/src/app/page.tsx | 305 +++++++----------- .../components/navBars/mainMapNavigation.tsx | 17 +- .../app/workspace/[workspaceId]/actions.ts | 204 ------------ .../[workspaceId]/actions/lib /auth.ts | 12 + .../[workspaceId]/actions/projects/create.ts | 36 +++ .../[workspaceId]/actions/projects/get.ts | 45 +++ .../[workspaceId]/actions/projects/index.ts | 3 + .../[workspaceId]/actions/projects/types.ts | 10 + .../[workspaceId]/actions/workspace/index.ts | 2 + .../actions/workspace/members.ts | 46 +++ .../[workspaceId]/actions/workspace/types.ts | 5 + .../[workspaceId]/addMemberModal.tsx | 144 +++++++++ .../src/app/workspace/[workspaceId]/page.tsx | 4 +- .../workspace/[workspaceId]/projectModal.tsx | 3 - .../[workspaceId]/workspaceProjects.tsx | 52 ++- gridwalk-ui/src/middleware.ts | 36 +-- 17 files changed, 545 insertions(+), 453 deletions(-) delete mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions/lib /auth.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/create.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/get.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/index.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/types.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/index.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/members.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/types.ts create mode 100644 gridwalk-ui/src/app/workspace/[workspaceId]/addMemberModal.tsx diff --git a/gridwalk-backend/src/routes/workspace.rs b/gridwalk-backend/src/routes/workspace.rs index d78ffcb..0a27af2 100644 --- a/gridwalk-backend/src/routes/workspace.rs +++ b/gridwalk-backend/src/routes/workspace.rs @@ -22,6 +22,13 @@ pub struct ReqCreateWorkspace { name: String, } +#[derive(Debug, Deserialize)] +pub struct ReqAddWorkspaceMember { + workspace_id: String, + email: String, + role: WorkspaceRole, +} + impl Workspace { pub fn from_req(req: ReqCreateWorkspace, owner: String) -> Self { Workspace { @@ -59,29 +66,35 @@ pub async fn create_workspace( } } -#[derive(Debug, Deserialize)] -pub struct ReqAddWorkspaceMember { - workspace_id: String, - user_id: String, - role: WorkspaceRole, -} - pub async fn add_workspace_member( State(state): State>, Extension(auth_user): Extension, - Json(req): Json, + Json(req): Json, // Using your existing struct ) -> Response { if let Some(req_user) = auth_user.user { - // TODO: sort out unwraps and response - let wsp = Workspace::from_id(&state.app_data, &req.workspace_id) + // Get the target user by email + let user_to_add = match User::from_email(&state.app_data, &req.email).await { + Ok(user) => user, + Err(_) => return "user not found".into_response(), + }; + + // Get the workspace + let workspace = match Workspace::from_id(&state.app_data, &req.workspace_id).await { + Ok(ws) => ws, + Err(_) => return "workspace not found".into_response(), + }; + + // Attempt to add member (role is already parsed since you use WorkspaceRole in ReqAddWorkspaceMember) + match workspace + .add_member(&state.app_data, &req_user, &user_to_add, req.role) .await - .unwrap(); - let user = User::from_id(&state.app_data, &req.user_id).await.unwrap(); - let _ = wsp - .add_member(&state.app_data, &req_user, &user, req.role) - .await; - }; - "added workspace member".into_response() + { + Ok(_) => "member added".into_response(), + Err(_) => "failed to add member".into_response(), + } + } else { + "unauthorized".into_response() + } } pub async fn remove_workspace_member( @@ -90,14 +103,25 @@ pub async fn remove_workspace_member( Json(req): Json, ) -> Response { if let Some(req_user) = auth_user.user { - // TODO: sort out unwraps and response - let wsp = Workspace::from_id(&state.app_data, &req.workspace_id) - .await - .unwrap(); - let user = User::from_id(&state.app_data, &req.user_id).await.unwrap(); - let _ = wsp.remove_member(&state.app_data, &req_user, &user).await; - }; - "removed workspace member".into_response() + // Get the workspace + let wsp = match Workspace::from_id(&state.app_data, &req.workspace_id).await { + Ok(ws) => ws, + Err(_) => return "workspace not found".into_response(), + }; + + // Get the user to remove by email instead of id + let user = match User::from_email(&state.app_data, &req.email).await { + Ok(user) => user, + Err(_) => return "user not found".into_response(), + }; + + match wsp.remove_member(&state.app_data, &req_user, &user).await { + Ok(_) => "removed workspace member".into_response(), + Err(_) => "failed to remove member".into_response(), + } + } else { + "unauthorized".into_response() + } } pub async fn get_workspaces( diff --git a/gridwalk-ui/src/app/page.tsx b/gridwalk-ui/src/app/page.tsx index 61f2461..3deb253 100644 --- a/gridwalk-ui/src/app/page.tsx +++ b/gridwalk-ui/src/app/page.tsx @@ -3,57 +3,19 @@ import { Database, Check, Star, - Lock, + Coins, Zap, Map, Activity, Users, + Mail, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import React, { useRef, useState } from "react"; import GridBackground from "./login/components/gridBackground"; import { useRouter } from "next/navigation"; -import { saveEmail } from "./actions"; export default function Home() { const router = useRouter(); - const [email, setEmail] = useState(""); - const [isSubmitted, setIsSubmitted] = useState(false); - const [error, setError] = useState(""); - const emailFormRef = useRef(null); - - const scrollToEmailForm = () => { - const element = emailFormRef.current; - if (element) { - const elementRect = element.getBoundingClientRect(); - const absoluteElementTop = elementRect.top + window.pageYOffset; - const middle = - absoluteElementTop - window.innerHeight / 2 + elementRect.height / 2; - window.scrollTo({ top: middle, behavior: "smooth" }); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - try { - const result = await saveEmail(email); - - if (result.success) { - setIsSubmitted(true); - setError(""); - setTimeout(() => { - setEmail(""); - setIsSubmitted(false); - }, 3000); - } else { - setError("Failed to save email. Please try again."); - } - } catch (err) { - setError("An error occurred. Please try again."); - console.error("Error in handleSubmit:", err); - } - }; const handleLoginRedirect = () => { router.push("/login"); @@ -91,11 +53,8 @@ export default function Home() {
- Early Access Available + Early Access Available During MVP Phase -
- 50% OFF -

@@ -105,63 +64,11 @@ export default function Home() {

- GridWalk transforms complex location data into crystal-clear - insights. Build beautiful maps, analyze patterns, and share - discoveries with your team in minutes. + GridWalk provides a collaborative environment to manage your + location data and turn it into crystal-clear insights. Build + beautiful maps, analyse patterns, and share discoveries with + your team in minutes.

- - {/* CTA Section */} -
-
-
-
- setEmail(e.target.value)} - className="flex-1 h-14 text-md pl-5 pr-12 rounded-xl bg-white/10 backdrop-blur-sm border-blue-300/20 text-white placeholder:text-gray-400" - required - /> - {isSubmitted && ( -
- -
- )} -
- -
- {error && ( -

{error}

- )} -
- - {/* Social Proof */} -
-
- {[...Array(5)].map((_, i) => ( -
- ))} -
-

- 180+ teams{" "} - already on the waitlist -

-
-
@@ -175,32 +82,32 @@ export default function Home() { Powerful Features

- Everything You Need for Modern Mapping + Everything You Need from a Modern Mapping Application

{[ { - icon: Activity, - title: "Real-time Analysis", + icon: Database, + title: "Workspace Management", description: - "Watch your data come alive with instant updates and dynamic visualizations.", - benefit: "Live data processing", + "A space to manage your geospatial data projects efficiently - providing a workspace environment that drives collaboration.", + benefit: "Team collaboration", }, { - icon: Database, - title: "Universal Compatibility", + icon: Activity, + title: "Flexible Data Integration", description: - "Import from any source: CSV, GeoJSON, Shapefiles, or connect your database directly.", - benefit: "Works with your stack", + "Upload data from the UI or connect your own database allowing you to use your geospatial data instantly.", + benefit: "Instant connectivity", }, { icon: Users, - title: "Team Collaboration", + title: "Intuitive Workflows", description: - "Built for teams with real-time editing, version control, and granular permissions.", - benefit: "True multiplayer", + "Intuitive workflows and UI to make sure you have more time to focus on your analysis and stakeholders.", + benefit: "Streamlined experience", }, ].map((feature, index) => (
{/* Pricing Section */} -
-
+
+
-
- - Lock In Launch Pricing +
+ + Simple Pricing Plans

- Early Adopter Pricing + Choose Your Plan

- Join now to secure lifetime discounted pricing + Flexible options for teams of all sizes

-
+ +
{[ { - name: "Starter", - price: "49", - originalPrice: "99", + name: "Free", + price: "0", + features: [ + "1 user", + "1 workspace", + "5 projects", + "500MB storage", + "Basic features", + ], + popular: false, + colors: { + card: "from-emerald-600/20 to-teal-600/20", + ring: "ring-emerald-500/30", + badge: "bg-emerald-400/10 text-emerald-300", + button: + "bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600", + check: "text-emerald-400", + }, + }, + { + name: "Solo", + price: "15", features: [ - "10GB storage", - "Up to 5 team members", - "Real-time analytics", + "1 user", + "5 workspaces", + "Unlimited projects", + "5GB storage", "Community support", ], popular: false, - discount: "50% OFF", + colors: { + card: "from-blue-600/20 to-cyan-600/20", + ring: "ring-blue-500/30", + badge: "bg-blue-400/10 text-blue-300", + button: + "bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600", + check: "text-blue-400", + }, }, { - name: "Professional", - price: "99", - originalPrice: "199", + name: "Team", + price: "50", features: [ - "50GB storage", - "up to 30 team members", - "Advanced analytics", + "15 users", + "15 workspaces", + "Unlimited projects", + "15GB storage", "Priority support", - "Custom styling", + "Advanced features", ], popular: true, - discount: "50% OFF", + colors: { + card: "from-purple-600/20 to-pink-600/20", + ring: "ring-purple-500/30", + badge: "bg-purple-400/10 text-purple-300", + button: + "bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600", + check: "text-purple-400", + }, }, { - name: "Enterprise", - price: "Custom", + name: "Organisation", + price: "150", features: [ - "Unlimited storage", - "Custom deployment", + "30 users", + "30 workspaces", + "Unlimited projects", + "20GB storage", "24/7 support", - "SLA guarantees", + "Custom integrations", "Advanced security", ], popular: false, + colors: { + card: "from-orange-600/20 to-red-600/20", + ring: "ring-orange-500/30", + badge: "bg-orange-400/10 text-orange-300", + button: + "bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600", + check: "text-orange-400", + }, }, ].map((plan, index) => (
- {plan.discount && ( -
- {plan.discount} -
- )}

{plan.name}

{plan.popular && ( -

+

Most popular

)}

- {plan.price !== "Custom" ? ( - <> - - ${plan.price} - - - /month - - {plan.originalPrice && ( - - ${plan.originalPrice} - - )} - - ) : ( - - Custom - - )} + + ${plan.price} + + + /month +

    {plan.features.map((feature) => (
  • - + {feature}
  • ))}
- + +
))}
@@ -360,15 +296,20 @@ export default function Home() {
{[ + { + name: "Contact", + href: "mailto:hello@enmeshed.dev", + icon: Mail, + }, { name: "Terms", href: "#" }, { name: "Privacy", href: "#" }, - { name: "Contact", href: "#" }, ].map((item) => ( + {item.icon && } {item.name} ))} diff --git a/gridwalk-ui/src/app/project/[workspaceId]/[projectName]/components/navBars/mainMapNavigation.tsx b/gridwalk-ui/src/app/project/[workspaceId]/[projectName]/components/navBars/mainMapNavigation.tsx index b38b608..2fe9bfd 100644 --- a/gridwalk-ui/src/app/project/[workspaceId]/[projectName]/components/navBars/mainMapNavigation.tsx +++ b/gridwalk-ui/src/app/project/[workspaceId]/[projectName]/components/navBars/mainMapNavigation.tsx @@ -1,4 +1,4 @@ -'use client' +"use client"; import React from "react"; import { Map, @@ -25,7 +25,7 @@ const MapModal: React.FC = ({ uploadSuccess, }) => { const router = useRouter(); - + const MainMapNavs: MainMapNav[] = [ { id: "map", @@ -43,7 +43,8 @@ const MapModal: React.FC = ({ id: "upload", title: "File Upload", icon: "file", - description: "Upload files and add a layer to the map", + description: + "Upload files and add a layer to the map. Currently accepts .geojson, .json, and .gpkg files.", }, { id: "settings", @@ -172,12 +173,12 @@ const MapModal: React.FC = ({ ))} - + {/* Back Button */}
- + {/* Modal Content */} {isOpen && selectedItem && (
= ({ className="absolute right-2 top-2 p-1 hover:bg-gray-100 rounded-full transition-colors" aria-label="Close modal" > - + {/* Content with black text override */}
diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions.ts deleted file mode 100644 index f3b9034..0000000 --- a/gridwalk-ui/src/app/workspace/[workspaceId]/actions.ts +++ /dev/null @@ -1,204 +0,0 @@ -"use server"; - -import { cookies } from "next/headers"; -import { NextResponse } from "next/server"; - -// CREATE PROJECTS -export type CreateProjectRequest = { - name: string; - workspace_id: string; -}; - -export type ProjectData = { - id: string; - name: string; - workspace_id: string; -}; - -export async function createProject( - data: CreateProjectRequest, -): Promise { - const cookieStore = await cookies(); - const sid = cookieStore.get("sid"); - - if (!sid?.value) { - throw new Error("Authentication token not found"); - } - - if (!data.workspace_id) { - throw new Error("Workspace ID is required"); - } - - if (!data.name) { - throw new Error("Project name is required"); - } - - const response = await fetch(`${process.env.GRIDWALK_API}/create_project`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sid.value}`, - }, - body: JSON.stringify({ - workspace_id: data.workspace_id, - name: data.name.trim(), - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (response.status === 401) { - throw new Error("Authentication failed"); - } - throw new Error(errorText || "Failed to create project"); - } - - const projectData = await response.json(); - return projectData; -} - -export async function POST(request: Request) { - try { - const body = (await request.json()) as CreateProjectRequest; - const project = await createProject(body); - - return NextResponse.json( - { - success: true, - data: project, - }, - { status: 201 }, - ); - } catch (error: unknown) { - console.error("Project creation error:", { - message: error instanceof Error ? error.message : "Unknown error", - stack: error instanceof Error ? error.stack : "No stack trace", - }); - - if (error instanceof Error && error.message.includes("Authentication")) { - return NextResponse.json( - { - success: false, - error: "Authentication failed. Please log in again.", - }, - { status: 401 }, - ); - } - - return NextResponse.json( - { - success: false, - error: - error instanceof Error ? error.message : "Failed to create project", - }, - { status: 400 }, - ); - } -} - -// GET PROJECTS -export type ApiResponse = { - status: string; - data: string[]; - error: string | null; -}; - -export type ProjectsResponse = { - success: boolean; - data?: string[]; - error?: string; -}; - -export async function getProjectsServer( - workspaceId: string, -): Promise { - const cookieStore = await cookies(); - const sid = cookieStore.get("sid"); - - if (!sid?.value) { - throw new Error("Authentication token not found"); - } - - if (!workspaceId) { - throw new Error("Workspace ID is required"); - } - - const response = await fetch( - `${process.env.GRIDWALK_API}/projects?workspace_id=${workspaceId}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sid.value}`, - }, - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - if (response.status === 401) { - throw new Error("Authentication failed"); - } - throw new Error(errorText || "Failed to fetch projects"); - } - - const projectsData = await response.json(); - - if (!Array.isArray(projectsData.data)) { - console.warn("Projects data is not an array:", projectsData.data); - return []; - } - - return projectsData.data; -} - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const workspaceId = searchParams.get("workspace_id"); - - if (!workspaceId) { - return NextResponse.json( - { - success: false, - error: "Workspace ID is required", - }, - { status: 400 }, - ); - } - - const projects = await getProjectsServer(workspaceId); - - return NextResponse.json( - { - success: true, - data: projects, - }, - { status: 200 }, - ); - } catch (error: unknown) { - console.error("Project fetch error:", { - message: error instanceof Error ? error.message : "Unknown error", - stack: error instanceof Error ? error.stack : "No stack trace", - }); - - if (error instanceof Error && error.message.includes("Authentication")) { - return NextResponse.json( - { - success: false, - error: "Authentication failed. Please log in again.", - }, - { status: 401 }, - ); - } - - return NextResponse.json( - { - success: false, - error: - error instanceof Error ? error.message : "Failed to fetch projects", - }, - { status: 400 }, - ); - } -} diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions/lib /auth.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/lib /auth.ts new file mode 100644 index 0000000..7db107e --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/lib /auth.ts @@ -0,0 +1,12 @@ +import { cookies } from "next/headers"; + +export async function getAuthToken() { + const cookieStore = await cookies(); + const sid = cookieStore.get("sid"); + + if (!sid?.value) { + throw new Error("Authentication token not found"); + } + + return sid.value; +} diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/create.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/create.ts new file mode 100644 index 0000000..fe65918 --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/create.ts @@ -0,0 +1,36 @@ +"use server"; + +import { getAuthToken } from "../lib /auth"; +import { CreateProjectRequest, ProjectData } from "./types"; +import { revalidatePath } from "next/cache"; + +export async function createProject( + data: CreateProjectRequest, +): Promise { + const token = await getAuthToken(); + + if (!data.workspace_id) throw new Error("Workspace ID is required"); + if (!data.name) throw new Error("Project name is required"); + + const response = await fetch(`${process.env.GRIDWALK_API}/create_project`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + workspace_id: data.workspace_id, + name: data.name.trim(), + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (response.status === 401) throw new Error("Authentication failed"); + throw new Error(errorText || "Failed to create project"); + } + + const projectData = await response.json(); + revalidatePath(`/workspaces/${data.workspace_id}/projects`); + return projectData; +} diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/get.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/get.ts new file mode 100644 index 0000000..84c0ff1 --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/get.ts @@ -0,0 +1,45 @@ +"use server"; + +import { getAuthToken } from "../lib /auth"; + +export async function getProjects(workspaceId: string): Promise { + const token = await getAuthToken(); + + if (!workspaceId) { + throw new Error("Workspace ID is required"); + } + + try { + const response = await fetch( + `${process.env.GRIDWALK_API}/projects?workspace_id=${workspaceId}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", // DO WE NEED THIS? + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + if (response.status === 401) { + throw new Error("Authentication failed"); + } + throw new Error(errorText || "Failed to fetch projects"); + } + + const projectsData = await response.json(); + + if (!Array.isArray(projectsData.data)) { + console.warn("Projects data is not an array:", projectsData.data); + return []; + } + + return projectsData.data; + } catch (error) { + console.error("Failed to fetch projects:", error); + throw error; // Re-throw to handle in the component + } +} diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/index.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/index.ts new file mode 100644 index 0000000..5287230 --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/index.ts @@ -0,0 +1,3 @@ +export * from "./get"; +export * from "./create"; +export * from "./types"; diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/types.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/types.ts new file mode 100644 index 0000000..e065148 --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/projects/types.ts @@ -0,0 +1,10 @@ +export type CreateProjectRequest = { + name: string; + workspace_id: string; +}; + +export type ProjectData = { + id: string; + name: string; + workspace_id: string; +}; diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/index.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/index.ts new file mode 100644 index 0000000..fd61e49 --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/index.ts @@ -0,0 +1,2 @@ +export * from "./members"; +export * from "./types"; diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/members.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/members.ts new file mode 100644 index 0000000..566ef22 --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/members.ts @@ -0,0 +1,46 @@ +"use server"; + +import { getAuthToken } from "../lib /auth"; +import { AddWorkspaceMemberRequest } from "./types"; +import { revalidatePath } from "next/cache"; + +export async function addWorkspaceMember( + data: AddWorkspaceMemberRequest, +): Promise { + const token = await getAuthToken(); + + // Validation + if (!data.workspace_id) throw new Error("Workspace ID is required"); + if (!data.email) throw new Error("Email is required"); + if (!data.role) throw new Error("Role is required"); + + try { + const response = await fetch( + `${process.env.GRIDWALK_API}/workspace/members`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + workspace_id: data.workspace_id, + email: data.email.trim(), + role: data.role, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + if (response.status === 401) throw new Error("Authentication failed"); + throw new Error(errorText || "Failed to add workspace member"); + } + + // Revalidate the members list page/cache + revalidatePath(`/workspaces/${data.workspace_id}/members`); + } catch (error) { + console.error("Failed to add workspace member:", error); + throw error; // Re-throw to handle in the component + } +} diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/types.ts b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/types.ts new file mode 100644 index 0000000..479261b --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/actions/workspace/types.ts @@ -0,0 +1,5 @@ +export type AddWorkspaceMemberRequest = { + workspace_id: string; + email: string; + role: "Admin" | "Read"; +}; diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/addMemberModal.tsx b/gridwalk-ui/src/app/workspace/[workspaceId]/addMemberModal.tsx new file mode 100644 index 0000000..053817e --- /dev/null +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/addMemberModal.tsx @@ -0,0 +1,144 @@ +"use client"; +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; + +interface AddWorkspaceMemberModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (email: string, role: "Admin" | "Read") => Promise; +} + +export const AddWorkspaceMemberModal: React.FC< + AddWorkspaceMemberModalProps +> = ({ isOpen, onClose, onSubmit }) => { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"Admin" | "Read">("Read"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + + try { + await onSubmit(email.trim(), role); + onClose(); + setEmail(""); + setRole("Read"); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

+ Add Workspace Member +

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md text-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="colleague@company.com" + required + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +}; + +const LoadingSpinner: React.FC = () => ( + + + + +); diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/page.tsx b/gridwalk-ui/src/app/workspace/[workspaceId]/page.tsx index f5efb66..5ead02e 100644 --- a/gridwalk-ui/src/app/workspace/[workspaceId]/page.tsx +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/page.tsx @@ -1,4 +1,4 @@ -import { getProjectsServer } from "./actions"; +import { getProjects } from "./actions/projects/get"; import WorkspaceProjectsClient from "./workspaceProjects"; type PageProps = { @@ -7,7 +7,7 @@ type PageProps = { export default async function Page({ params }: PageProps) { const { workspaceId } = await params; - const initialProjects = await getProjectsServer(workspaceId); + const initialProjects = await getProjects(workspaceId); return ( = ({ onClose, onSubmit, }) => { - const router = useRouter(); const [projectName, setProjectName] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -23,7 +21,6 @@ export const CreateProjectModal: React.FC = ({ await onSubmit(projectName.trim()); onClose(); setProjectName(""); - router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { diff --git a/gridwalk-ui/src/app/workspace/[workspaceId]/workspaceProjects.tsx b/gridwalk-ui/src/app/workspace/[workspaceId]/workspaceProjects.tsx index c4778c4..7145470 100644 --- a/gridwalk-ui/src/app/workspace/[workspaceId]/workspaceProjects.tsx +++ b/gridwalk-ui/src/app/workspace/[workspaceId]/workspaceProjects.tsx @@ -1,10 +1,13 @@ "use client"; import React, { useState } from "react"; -import { Plus } from "lucide-react"; +import { Plus, UserPlus } from "lucide-react"; import { CreateProjectModal } from "./projectModal"; +import { AddWorkspaceMemberModal } from "./addMemberModal"; import { useWorkspaces } from "../workspaceContext"; -import { createProject } from "./actions"; +import { createProject } from "./actions/projects/create"; +import { addWorkspaceMember } from "./actions/workspace"; import { useRouter } from "next/navigation"; + interface WorkspaceProjectsClientProps { workspaceId: string; initialProjects: string[]; @@ -18,6 +21,7 @@ export default function WorkspaceProjectsClient({ const { workspaces } = useWorkspaces(); const [projects, setProjects] = useState(initialProjects); const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false); + const [isMemberDialogOpen, setIsMemberDialogOpen] = useState(false); const currentWorkspace = workspaces.find((w) => w.id === workspaceId); const handleCreateProject = async (name: string) => { @@ -43,6 +47,22 @@ export default function WorkspaceProjectsClient({ router.push(`/project/${workspaceId}/${safeProjectName}`); }; + const handleAddMember = async (email: string, role: "Admin" | "Read") => { + try { + await addWorkspaceMember({ + workspace_id: workspaceId, + email: email.trim(), + role, + }); + setIsMemberDialogOpen(false); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(error.message || "Failed to add member"); + } + throw new Error("Failed to add member"); + } + }; + return (
@@ -54,6 +74,13 @@ export default function WorkspaceProjectsClient({

+
)}
- {isProjectDialogOpen && ( - setIsProjectDialogOpen(false)} - onSubmit={handleCreateProject} - /> - )} + + setIsProjectDialogOpen(false)} + onSubmit={handleCreateProject} + /> + + setIsMemberDialogOpen(false)} + onSubmit={handleAddMember} + />
); diff --git a/gridwalk-ui/src/middleware.ts b/gridwalk-ui/src/middleware.ts index 6456f79..3ac938b 100644 --- a/gridwalk-ui/src/middleware.ts +++ b/gridwalk-ui/src/middleware.ts @@ -1,47 +1,45 @@ -// middleware.ts -import { NextResponse } from 'next/server' -import type { NextRequest } from 'next/server' +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; export async function middleware(request: NextRequest) { // Only run this middleware for the login page - if (request.nextUrl.pathname === '/login') { - const sessionId = request.cookies.get('sid') + if (request.nextUrl.pathname === "/login") { + const sessionId = request.cookies.get("sid"); // If no session cookie exists, allow access to login page if (!sessionId) { - return NextResponse.next() + return NextResponse.next(); } try { - const apiHost = process.env.GRIDWALK_API + const apiHost = process.env.GRIDWALK_API; if (!apiHost) { - throw new Error('GRIDWALK_API environment variable is not set') + throw new Error("GRIDWALK_API environment variable is not set"); } const response = await fetch(`${apiHost}/profile`, { headers: { - 'Authorization': `Bearer ${sessionId.value}` - } - }) + Authorization: `Bearer ${sessionId.value}`, + }, + }); // If authenticated, redirect to workspace if (response.ok) { - return NextResponse.redirect(new URL('/workspace', request.url)) + return NextResponse.redirect(new URL("/workspace", request.url)); } // If not authenticated, allow access to login page - return NextResponse.next() - + return NextResponse.next(); } catch (error) { - console.error('Error checking auth status:', error) - return NextResponse.next() + console.error("Error checking auth status:", error); + return NextResponse.next(); } } - return NextResponse.next() + return NextResponse.next(); } // Configure the middleware to only run on the login page export const config = { - matcher: '/login' -} + matcher: "/login", +};